mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +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,
|
||||||
|
)
|
||||||
244
src/App.tsx
244
src/App.tsx
@@ -1,116 +1,112 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from './store/auth'
|
import { useAuthStore } from './store/auth';
|
||||||
import { useBlockingStore } from './store/blocking'
|
import { useBlockingStore } from './store/blocking';
|
||||||
import Layout from './components/layout/Layout'
|
import Layout from './components/layout/Layout';
|
||||||
import PageLoader from './components/common/PageLoader'
|
import PageLoader from './components/common/PageLoader';
|
||||||
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'
|
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
|
||||||
import { saveReturnUrl } from './utils/token'
|
import { saveReturnUrl } from './utils/token';
|
||||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'
|
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||||
|
|
||||||
// Auth pages - load immediately (small)
|
// Auth pages - load immediately (small)
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login';
|
||||||
import TelegramCallback from './pages/TelegramCallback'
|
import TelegramCallback from './pages/TelegramCallback';
|
||||||
import TelegramRedirect from './pages/TelegramRedirect'
|
import TelegramRedirect from './pages/TelegramRedirect';
|
||||||
import DeepLinkRedirect from './pages/DeepLinkRedirect'
|
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
||||||
import VerifyEmail from './pages/VerifyEmail'
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
import ResetPassword from './pages/ResetPassword'
|
import ResetPassword from './pages/ResetPassword';
|
||||||
|
|
||||||
// User pages - lazy load
|
// User pages - lazy load
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
const Subscription = lazy(() => import('./pages/Subscription'))
|
const Subscription = lazy(() => import('./pages/Subscription'));
|
||||||
const Balance = lazy(() => import('./pages/Balance'))
|
const Balance = lazy(() => import('./pages/Balance'));
|
||||||
const Referral = lazy(() => import('./pages/Referral'))
|
const Referral = lazy(() => import('./pages/Referral'));
|
||||||
const Support = lazy(() => import('./pages/Support'))
|
const Support = lazy(() => import('./pages/Support'));
|
||||||
const Profile = lazy(() => import('./pages/Profile'))
|
const Profile = lazy(() => import('./pages/Profile'));
|
||||||
const Contests = lazy(() => import('./pages/Contests'))
|
const Contests = lazy(() => import('./pages/Contests'));
|
||||||
const Polls = lazy(() => import('./pages/Polls'))
|
const Polls = lazy(() => import('./pages/Polls'));
|
||||||
const Info = lazy(() => import('./pages/Info'))
|
const Info = lazy(() => import('./pages/Info'));
|
||||||
const Wheel = lazy(() => import('./pages/Wheel'))
|
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||||
|
|
||||||
// Admin pages - lazy load (only for admins)
|
// Admin pages - lazy load (only for admins)
|
||||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'))
|
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
||||||
const AdminTickets = lazy(() => import('./pages/AdminTickets'))
|
const AdminTickets = lazy(() => import('./pages/AdminTickets'));
|
||||||
const AdminSettings = lazy(() => import('./pages/AdminSettings'))
|
const AdminSettings = lazy(() => import('./pages/AdminSettings'));
|
||||||
const AdminApps = lazy(() => import('./pages/AdminApps'))
|
const AdminApps = lazy(() => import('./pages/AdminApps'));
|
||||||
const AdminWheel = lazy(() => import('./pages/AdminWheel'))
|
const AdminWheel = lazy(() => import('./pages/AdminWheel'));
|
||||||
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'))
|
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
|
||||||
const AdminServers = lazy(() => import('./pages/AdminServers'))
|
const AdminServers = lazy(() => import('./pages/AdminServers'));
|
||||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'))
|
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
||||||
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'))
|
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
|
||||||
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'))
|
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
|
||||||
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'))
|
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
|
||||||
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'))
|
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
|
||||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'))
|
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'))
|
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'))
|
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'))
|
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
||||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'))
|
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'))
|
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore()
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageLoader variant="dark" />
|
return <PageLoader variant="dark" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Сохраняем текущий URL для возврата после авторизации
|
// Сохраняем текущий URL для возврата после авторизации
|
||||||
saveReturnUrl()
|
saveReturnUrl();
|
||||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Layout>{children}</Layout>
|
return <Layout>{children}</Layout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading, isAdmin } = useAuthStore()
|
const { isAuthenticated, isLoading, isAdmin } = useAuthStore();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <PageLoader variant="light" />
|
return <PageLoader variant="light" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Сохраняем текущий URL для возврата после авторизации
|
// Сохраняем текущий URL для возврата после авторизации
|
||||||
saveReturnUrl()
|
saveReturnUrl();
|
||||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return <Navigate to="/" replace />
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Layout>{children}</Layout>
|
return <Layout>{children}</Layout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suspense wrapper for lazy components
|
// Suspense wrapper for lazy components
|
||||||
function LazyPage({ children }: { children: React.ReactNode }) {
|
function LazyPage({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <Suspense fallback={<PageLoader variant="dark" />}>{children}</Suspense>;
|
||||||
<Suspense fallback={<PageLoader variant="dark" />}>
|
|
||||||
{children}
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlockingOverlay() {
|
function BlockingOverlay() {
|
||||||
const { blockingType } = useBlockingStore()
|
const { blockingType } = useBlockingStore();
|
||||||
|
|
||||||
if (blockingType === 'maintenance') {
|
if (blockingType === 'maintenance') {
|
||||||
return <MaintenanceScreen />
|
return <MaintenanceScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockingType === 'channel_subscription') {
|
if (blockingType === 'channel_subscription') {
|
||||||
return <ChannelSubscriptionScreen />
|
return <ChannelSubscriptionScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useAnalyticsCounters()
|
useAnalyticsCounters();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -131,7 +127,9 @@ function App() {
|
|||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Dashboard /></LazyPage>
|
<LazyPage>
|
||||||
|
<Dashboard />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +137,9 @@ function App() {
|
|||||||
path="/subscription"
|
path="/subscription"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Subscription /></LazyPage>
|
<LazyPage>
|
||||||
|
<Subscription />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -147,7 +147,9 @@ function App() {
|
|||||||
path="/balance"
|
path="/balance"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Balance /></LazyPage>
|
<LazyPage>
|
||||||
|
<Balance />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -155,7 +157,9 @@ function App() {
|
|||||||
path="/referral"
|
path="/referral"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Referral /></LazyPage>
|
<LazyPage>
|
||||||
|
<Referral />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -163,7 +167,9 @@ function App() {
|
|||||||
path="/support"
|
path="/support"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Support /></LazyPage>
|
<LazyPage>
|
||||||
|
<Support />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -171,7 +177,9 @@ function App() {
|
|||||||
path="/profile"
|
path="/profile"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Profile /></LazyPage>
|
<LazyPage>
|
||||||
|
<Profile />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -179,7 +187,9 @@ function App() {
|
|||||||
path="/contests"
|
path="/contests"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Contests /></LazyPage>
|
<LazyPage>
|
||||||
|
<Contests />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -187,7 +197,9 @@ function App() {
|
|||||||
path="/polls"
|
path="/polls"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Polls /></LazyPage>
|
<LazyPage>
|
||||||
|
<Polls />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -195,7 +207,9 @@ function App() {
|
|||||||
path="/info"
|
path="/info"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Info /></LazyPage>
|
<LazyPage>
|
||||||
|
<Info />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +217,9 @@ function App() {
|
|||||||
path="/wheel"
|
path="/wheel"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage><Wheel /></LazyPage>
|
<LazyPage>
|
||||||
|
<Wheel />
|
||||||
|
</LazyPage>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -213,7 +229,9 @@ function App() {
|
|||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminPanel /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminPanel />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -221,7 +239,9 @@ function App() {
|
|||||||
path="/admin/tickets"
|
path="/admin/tickets"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminTickets /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminTickets />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -229,7 +249,9 @@ function App() {
|
|||||||
path="/admin/settings"
|
path="/admin/settings"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminSettings /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminSettings />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -237,7 +259,9 @@ function App() {
|
|||||||
path="/admin/apps"
|
path="/admin/apps"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminApps /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminApps />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -245,7 +269,9 @@ function App() {
|
|||||||
path="/admin/wheel"
|
path="/admin/wheel"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminWheel /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminWheel />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -253,7 +279,9 @@ function App() {
|
|||||||
path="/admin/tariffs"
|
path="/admin/tariffs"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminTariffs /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminTariffs />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -261,7 +289,9 @@ function App() {
|
|||||||
path="/admin/servers"
|
path="/admin/servers"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminServers /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminServers />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -269,7 +299,9 @@ function App() {
|
|||||||
path="/admin/dashboard"
|
path="/admin/dashboard"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminDashboard /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminDashboard />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -277,7 +309,9 @@ function App() {
|
|||||||
path="/admin/ban-system"
|
path="/admin/ban-system"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminBanSystem /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminBanSystem />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -285,7 +319,9 @@ function App() {
|
|||||||
path="/admin/broadcasts"
|
path="/admin/broadcasts"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminBroadcasts /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminBroadcasts />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -293,7 +329,9 @@ function App() {
|
|||||||
path="/admin/promocodes"
|
path="/admin/promocodes"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminPromocodes /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminPromocodes />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -301,7 +339,9 @@ function App() {
|
|||||||
path="/admin/campaigns"
|
path="/admin/campaigns"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminCampaigns /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminCampaigns />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -309,7 +349,9 @@ function App() {
|
|||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminUsers /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminUsers />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -317,7 +359,9 @@ function App() {
|
|||||||
path="/admin/payments"
|
path="/admin/payments"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminPayments /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminPayments />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -325,7 +369,9 @@ function App() {
|
|||||||
path="/admin/payment-methods"
|
path="/admin/payment-methods"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminPaymentMethods /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminPaymentMethods />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -333,7 +379,9 @@ function App() {
|
|||||||
path="/admin/promo-offers"
|
path="/admin/promo-offers"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminPromoOffers /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminPromoOffers />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -341,7 +389,9 @@ function App() {
|
|||||||
path="/admin/remnawave"
|
path="/admin/remnawave"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminRemnawave /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminRemnawave />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -349,7 +399,9 @@ function App() {
|
|||||||
path="/admin/email-templates"
|
path="/admin/email-templates"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<LazyPage><AdminEmailTemplates /></LazyPage>
|
<LazyPage>
|
||||||
|
<AdminEmailTemplates />
|
||||||
|
</LazyPage>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -358,7 +410,7 @@ function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
434
src/api/admin.ts
434
src/api/admin.ts
@@ -1,306 +1,310 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface AdminTicketUser {
|
export interface AdminTicketUser {
|
||||||
id: number
|
id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
last_name: string | null
|
last_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTicketMessage {
|
export interface AdminTicketMessage {
|
||||||
id: number
|
id: number;
|
||||||
message_text: string
|
message_text: string;
|
||||||
is_from_admin: boolean
|
is_from_admin: boolean;
|
||||||
has_media: boolean
|
has_media: boolean;
|
||||||
media_type: string | null
|
media_type: string | null;
|
||||||
media_file_id: string | null
|
media_file_id: string | null;
|
||||||
media_caption: string | null
|
media_caption: string | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTicket {
|
export interface AdminTicket {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
status: string
|
status: string;
|
||||||
priority: string
|
priority: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
closed_at: string | null
|
closed_at: string | null;
|
||||||
messages_count: number
|
messages_count: number;
|
||||||
user: AdminTicketUser | null
|
user: AdminTicketUser | null;
|
||||||
last_message: AdminTicketMessage | null
|
last_message: AdminTicketMessage | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTicketDetail {
|
export interface AdminTicketDetail {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
status: string
|
status: string;
|
||||||
priority: string
|
priority: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
closed_at: string | null
|
closed_at: string | null;
|
||||||
is_reply_blocked: boolean
|
is_reply_blocked: boolean;
|
||||||
user: AdminTicketUser | null
|
user: AdminTicketUser | null;
|
||||||
messages: AdminTicketMessage[]
|
messages: AdminTicketMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTicketStats {
|
export interface AdminTicketStats {
|
||||||
total: number
|
total: number;
|
||||||
open: number
|
open: number;
|
||||||
pending: number
|
pending: number;
|
||||||
answered: number
|
answered: number;
|
||||||
closed: number
|
closed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketSettings {
|
export interface TicketSettings {
|
||||||
sla_enabled: boolean
|
sla_enabled: boolean;
|
||||||
sla_minutes: number
|
sla_minutes: number;
|
||||||
sla_check_interval_seconds: number
|
sla_check_interval_seconds: number;
|
||||||
sla_reminder_cooldown_minutes: number
|
sla_reminder_cooldown_minutes: number;
|
||||||
support_system_mode: string // tickets, contact, both
|
support_system_mode: string; // tickets, contact, both
|
||||||
cabinet_user_notifications_enabled: boolean
|
cabinet_user_notifications_enabled: boolean;
|
||||||
cabinet_admin_notifications_enabled: boolean
|
cabinet_admin_notifications_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketSettingsUpdate {
|
export interface TicketSettingsUpdate {
|
||||||
sla_enabled?: boolean
|
sla_enabled?: boolean;
|
||||||
sla_minutes?: number
|
sla_minutes?: number;
|
||||||
sla_check_interval_seconds?: number
|
sla_check_interval_seconds?: number;
|
||||||
sla_reminder_cooldown_minutes?: number
|
sla_reminder_cooldown_minutes?: number;
|
||||||
support_system_mode?: string
|
support_system_mode?: string;
|
||||||
cabinet_user_notifications_enabled?: boolean
|
cabinet_user_notifications_enabled?: boolean;
|
||||||
cabinet_admin_notifications_enabled?: boolean
|
cabinet_admin_notifications_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminTicketListResponse {
|
export interface AdminTicketListResponse {
|
||||||
items: AdminTicket[]
|
items: AdminTicket[];
|
||||||
total: number
|
total: number;
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
pages: number
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
// Check if current user is admin
|
// Check if current user is admin
|
||||||
checkIsAdmin: async (): Promise<{ is_admin: boolean }> => {
|
checkIsAdmin: async (): Promise<{ is_admin: boolean }> => {
|
||||||
const response = await apiClient.get('/cabinet/auth/me/is-admin')
|
const response = await apiClient.get('/cabinet/auth/me/is-admin');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get ticket statistics
|
// Get ticket statistics
|
||||||
getTicketStats: async (): Promise<AdminTicketStats> => {
|
getTicketStats: async (): Promise<AdminTicketStats> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/tickets/stats')
|
const response = await apiClient.get('/cabinet/admin/tickets/stats');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all tickets
|
// Get all tickets
|
||||||
getTickets: async (params: {
|
getTickets: async (
|
||||||
page?: number
|
params: {
|
||||||
per_page?: number
|
page?: number;
|
||||||
status?: string
|
per_page?: number;
|
||||||
priority?: string
|
status?: string;
|
||||||
} = {}): Promise<AdminTicketListResponse> => {
|
priority?: string;
|
||||||
const response = await apiClient.get('/cabinet/admin/tickets', { params })
|
} = {},
|
||||||
return response.data
|
): Promise<AdminTicketListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/tickets', { params });
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single ticket with messages
|
// Get single ticket with messages
|
||||||
getTicket: async (ticketId: number): Promise<AdminTicketDetail> => {
|
getTicket: async (ticketId: number): Promise<AdminTicketDetail> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`)
|
const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reply to ticket
|
// Reply to ticket
|
||||||
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => {
|
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message })
|
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update ticket status
|
// Update ticket status
|
||||||
updateTicketStatus: async (ticketId: number, status: string): Promise<AdminTicketDetail> => {
|
updateTicketStatus: async (ticketId: number, status: string): Promise<AdminTicketDetail> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status })
|
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update ticket priority
|
// Update ticket priority
|
||||||
updateTicketPriority: async (ticketId: number, priority: string): Promise<AdminTicketDetail> => {
|
updateTicketPriority: async (ticketId: number, priority: string): Promise<AdminTicketDetail> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority })
|
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, {
|
||||||
return response.data
|
priority,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get ticket settings
|
// Get ticket settings
|
||||||
getTicketSettings: async (): Promise<TicketSettings> => {
|
getTicketSettings: async (): Promise<TicketSettings> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/tickets/settings')
|
const response = await apiClient.get('/cabinet/admin/tickets/settings');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update ticket settings
|
// Update ticket settings
|
||||||
updateTicketSettings: async (settings: TicketSettingsUpdate): Promise<TicketSettings> => {
|
updateTicketSettings: async (settings: TicketSettingsUpdate): Promise<TicketSettings> => {
|
||||||
const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings)
|
const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// ============ Dashboard Stats Types ============
|
// ============ Dashboard Stats Types ============
|
||||||
|
|
||||||
export interface NodeStatus {
|
export interface NodeStatus {
|
||||||
uuid: string
|
uuid: string;
|
||||||
name: string
|
name: string;
|
||||||
address: string
|
address: string;
|
||||||
is_connected: boolean
|
is_connected: boolean;
|
||||||
is_disabled: boolean
|
is_disabled: boolean;
|
||||||
users_online: number
|
users_online: number;
|
||||||
traffic_used_bytes?: number
|
traffic_used_bytes?: number;
|
||||||
uptime?: string
|
uptime?: string;
|
||||||
xray_version?: string
|
xray_version?: string;
|
||||||
node_version?: string
|
node_version?: string;
|
||||||
last_status_message?: string
|
last_status_message?: string;
|
||||||
xray_uptime?: string
|
xray_uptime?: string;
|
||||||
is_xray_running?: boolean
|
is_xray_running?: boolean;
|
||||||
cpu_count?: number
|
cpu_count?: number;
|
||||||
cpu_model?: string
|
cpu_model?: string;
|
||||||
total_ram?: string
|
total_ram?: string;
|
||||||
country_code?: string
|
country_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodesOverview {
|
export interface NodesOverview {
|
||||||
total: number
|
total: number;
|
||||||
online: number
|
online: number;
|
||||||
offline: number
|
offline: number;
|
||||||
disabled: number
|
disabled: number;
|
||||||
total_users_online: number
|
total_users_online: number;
|
||||||
nodes: NodeStatus[]
|
nodes: NodeStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevenueData {
|
export interface RevenueData {
|
||||||
date: string
|
date: string;
|
||||||
amount_kopeks: number
|
amount_kopeks: number;
|
||||||
amount_rubles: number
|
amount_rubles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStats {
|
export interface SubscriptionStats {
|
||||||
total: number
|
total: number;
|
||||||
active: number
|
active: number;
|
||||||
trial: number
|
trial: number;
|
||||||
paid: number
|
paid: number;
|
||||||
expired: number
|
expired: number;
|
||||||
purchased_today: number
|
purchased_today: number;
|
||||||
purchased_week: number
|
purchased_week: number;
|
||||||
purchased_month: number
|
purchased_month: number;
|
||||||
trial_to_paid_conversion: number
|
trial_to_paid_conversion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinancialStats {
|
export interface FinancialStats {
|
||||||
income_today_kopeks: number
|
income_today_kopeks: number;
|
||||||
income_today_rubles: number
|
income_today_rubles: number;
|
||||||
income_month_kopeks: number
|
income_month_kopeks: number;
|
||||||
income_month_rubles: number
|
income_month_rubles: number;
|
||||||
income_total_kopeks: number
|
income_total_kopeks: number;
|
||||||
income_total_rubles: number
|
income_total_rubles: number;
|
||||||
subscription_income_kopeks: number
|
subscription_income_kopeks: number;
|
||||||
subscription_income_rubles: number
|
subscription_income_rubles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerStats {
|
export interface ServerStats {
|
||||||
total_servers: number
|
total_servers: number;
|
||||||
available_servers: number
|
available_servers: number;
|
||||||
servers_with_connections: number
|
servers_with_connections: number;
|
||||||
total_revenue_kopeks: number
|
total_revenue_kopeks: number;
|
||||||
total_revenue_rubles: number
|
total_revenue_rubles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffStatItem {
|
export interface TariffStatItem {
|
||||||
tariff_id: number
|
tariff_id: number;
|
||||||
tariff_name: string
|
tariff_name: string;
|
||||||
active_subscriptions: number
|
active_subscriptions: number;
|
||||||
trial_subscriptions: number
|
trial_subscriptions: number;
|
||||||
purchased_today: number
|
purchased_today: number;
|
||||||
purchased_week: number
|
purchased_week: number;
|
||||||
purchased_month: number
|
purchased_month: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffStats {
|
export interface TariffStats {
|
||||||
tariffs: TariffStatItem[]
|
tariffs: TariffStatItem[];
|
||||||
total_tariff_subscriptions: number
|
total_tariff_subscriptions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
nodes: NodesOverview
|
nodes: NodesOverview;
|
||||||
subscriptions: SubscriptionStats
|
subscriptions: SubscriptionStats;
|
||||||
financial: FinancialStats
|
financial: FinancialStats;
|
||||||
servers: ServerStats
|
servers: ServerStats;
|
||||||
revenue_chart: RevenueData[]
|
revenue_chart: RevenueData[];
|
||||||
tariff_stats?: TariffStats
|
tariff_stats?: TariffStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Extended Stats Types ============
|
// ============ Extended Stats Types ============
|
||||||
|
|
||||||
export interface TopReferrerItem {
|
export interface TopReferrerItem {
|
||||||
user_id: number
|
user_id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username?: string
|
username?: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
invited_count: number
|
invited_count: number;
|
||||||
invited_today: number
|
invited_today: number;
|
||||||
invited_week: number
|
invited_week: number;
|
||||||
invited_month: number
|
invited_month: number;
|
||||||
earnings_today_kopeks: number
|
earnings_today_kopeks: number;
|
||||||
earnings_week_kopeks: number
|
earnings_week_kopeks: number;
|
||||||
earnings_month_kopeks: number
|
earnings_month_kopeks: number;
|
||||||
earnings_total_kopeks: number
|
earnings_total_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopReferrersResponse {
|
export interface TopReferrersResponse {
|
||||||
by_earnings: TopReferrerItem[]
|
by_earnings: TopReferrerItem[];
|
||||||
by_invited: TopReferrerItem[]
|
by_invited: TopReferrerItem[];
|
||||||
total_referrers: number
|
total_referrers: number;
|
||||||
total_referrals: number
|
total_referrals: number;
|
||||||
total_earnings_kopeks: number
|
total_earnings_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopCampaignItem {
|
export interface TopCampaignItem {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
start_parameter: string
|
start_parameter: string;
|
||||||
bonus_type: string
|
bonus_type: string;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
registrations: number
|
registrations: number;
|
||||||
conversions: number
|
conversions: number;
|
||||||
conversion_rate: number
|
conversion_rate: number;
|
||||||
total_revenue_kopeks: number
|
total_revenue_kopeks: number;
|
||||||
avg_revenue_per_user_kopeks: number
|
avg_revenue_per_user_kopeks: number;
|
||||||
created_at?: string
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopCampaignsResponse {
|
export interface TopCampaignsResponse {
|
||||||
campaigns: TopCampaignItem[]
|
campaigns: TopCampaignItem[];
|
||||||
total_campaigns: number
|
total_campaigns: number;
|
||||||
total_registrations: number
|
total_registrations: number;
|
||||||
total_revenue_kopeks: number
|
total_revenue_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentPaymentItem {
|
export interface RecentPaymentItem {
|
||||||
id: number
|
id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username?: string
|
username?: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
amount_kopeks: number
|
amount_kopeks: number;
|
||||||
amount_rubles: number
|
amount_rubles: number;
|
||||||
type: string
|
type: string;
|
||||||
type_display: string
|
type_display: string;
|
||||||
payment_method?: string
|
payment_method?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
is_completed: boolean
|
is_completed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentPaymentsResponse {
|
export interface RecentPaymentsResponse {
|
||||||
payments: RecentPaymentItem[]
|
payments: RecentPaymentItem[];
|
||||||
total_count: number
|
total_count: number;
|
||||||
total_today_kopeks: number
|
total_today_kopeks: number;
|
||||||
total_week_kopeks: number
|
total_week_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Dashboard Stats API ============
|
// ============ Dashboard Stats API ============
|
||||||
@@ -308,43 +312,51 @@ export interface RecentPaymentsResponse {
|
|||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
// Get complete dashboard stats
|
// Get complete dashboard stats
|
||||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/stats/dashboard')
|
const response = await apiClient.get('/cabinet/admin/stats/dashboard');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get nodes status
|
// Get nodes status
|
||||||
getNodesStatus: async (): Promise<NodesOverview> => {
|
getNodesStatus: async (): Promise<NodesOverview> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/stats/nodes')
|
const response = await apiClient.get('/cabinet/admin/stats/nodes');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Restart a node
|
// Restart a node
|
||||||
restartNode: async (nodeUuid: string): Promise<{ success: boolean; message: string }> => {
|
restartNode: async (nodeUuid: string): Promise<{ success: boolean; message: string }> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`)
|
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle node (enable/disable)
|
// Toggle node (enable/disable)
|
||||||
toggleNode: async (nodeUuid: string): Promise<{ success: boolean; message: string; is_disabled: boolean }> => {
|
toggleNode: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`)
|
nodeUuid: string,
|
||||||
return response.data
|
): Promise<{ success: boolean; message: string; is_disabled: boolean }> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get top referrers
|
// Get top referrers
|
||||||
getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => {
|
getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } })
|
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', {
|
||||||
return response.data
|
params: { limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get top campaigns
|
// Get top campaigns
|
||||||
getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => {
|
getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } })
|
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', {
|
||||||
return response.data
|
params: { limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get recent payments
|
// Get recent payments
|
||||||
getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => {
|
getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } })
|
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', {
|
||||||
return response.data
|
params: { limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,220 +1,240 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface LocalizedText {
|
export interface LocalizedText {
|
||||||
en: string
|
en: string;
|
||||||
ru: string
|
ru: string;
|
||||||
zh?: string
|
zh?: string;
|
||||||
fa?: string
|
fa?: string;
|
||||||
fr?: string
|
fr?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppButton {
|
export interface AppButton {
|
||||||
id?: string // Unique identifier for React key (client-side only)
|
id?: string; // Unique identifier for React key (client-side only)
|
||||||
buttonLink: string
|
buttonLink: string;
|
||||||
buttonText: LocalizedText
|
buttonText: LocalizedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppStep {
|
export interface AppStep {
|
||||||
description: LocalizedText
|
description: LocalizedText;
|
||||||
buttons?: AppButton[]
|
buttons?: AppButton[];
|
||||||
title?: LocalizedText
|
title?: LocalizedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppDefinition {
|
export interface AppDefinition {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
isFeatured: boolean
|
isFeatured: boolean;
|
||||||
urlScheme: string
|
urlScheme: string;
|
||||||
isNeedBase64Encoding?: boolean
|
isNeedBase64Encoding?: boolean;
|
||||||
installationStep: AppStep
|
installationStep: AppStep;
|
||||||
addSubscriptionStep: AppStep
|
addSubscriptionStep: AppStep;
|
||||||
connectAndUseStep: AppStep
|
connectAndUseStep: AppStep;
|
||||||
additionalBeforeAddSubscriptionStep?: AppStep
|
additionalBeforeAddSubscriptionStep?: AppStep;
|
||||||
additionalAfterAddSubscriptionStep?: AppStep
|
additionalAfterAddSubscriptionStep?: AppStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfigBranding {
|
export interface AppConfigBranding {
|
||||||
name: string
|
name: string;
|
||||||
logoUrl: string
|
logoUrl: string;
|
||||||
supportUrl: string
|
supportUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfigConfig {
|
export interface AppConfigConfig {
|
||||||
additionalLocales: string[]
|
additionalLocales: string[];
|
||||||
branding: AppConfigBranding
|
branding: AppConfigBranding;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfigResponse {
|
export interface AppConfigResponse {
|
||||||
config: AppConfigConfig
|
config: AppConfigConfig;
|
||||||
platforms: Record<string, AppDefinition[]>
|
platforms: Record<string, AppDefinition[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminAppsApi = {
|
export const adminAppsApi = {
|
||||||
// Get full app config
|
// Get full app config
|
||||||
getConfig: async (): Promise<AppConfigResponse> => {
|
getConfig: async (): Promise<AppConfigResponse> => {
|
||||||
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps')
|
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available platforms
|
// Get available platforms
|
||||||
getPlatforms: async (): Promise<string[]> => {
|
getPlatforms: async (): Promise<string[]> => {
|
||||||
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms')
|
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get apps for a platform
|
// Get apps for a platform
|
||||||
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
|
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
|
||||||
const response = await apiClient.get<AppDefinition[]>(`/cabinet/admin/apps/platforms/${platform}`)
|
const response = await apiClient.get<AppDefinition[]>(
|
||||||
return response.data
|
`/cabinet/admin/apps/platforms/${platform}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create a new app
|
// Create a new app
|
||||||
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
|
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
|
||||||
const response = await apiClient.post<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}`, {
|
const response = await apiClient.post<AppDefinition>(
|
||||||
|
`/cabinet/admin/apps/platforms/${platform}`,
|
||||||
|
{
|
||||||
platform,
|
platform,
|
||||||
app,
|
app,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update an app
|
// Update an app
|
||||||
updateApp: async (platform: string, appId: string, app: AppDefinition): Promise<AppDefinition> => {
|
updateApp: async (
|
||||||
const response = await apiClient.put<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}/${appId}`, {
|
platform: string,
|
||||||
|
appId: string,
|
||||||
|
app: AppDefinition,
|
||||||
|
): Promise<AppDefinition> => {
|
||||||
|
const response = await apiClient.put<AppDefinition>(
|
||||||
|
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
|
||||||
|
{
|
||||||
app,
|
app,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete an app
|
// Delete an app
|
||||||
deleteApp: async (platform: string, appId: string): Promise<void> => {
|
deleteApp: async (platform: string, appId: string): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`)
|
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reorder apps
|
// Reorder apps
|
||||||
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
|
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
|
||||||
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
|
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
|
||||||
app_ids: appIds,
|
app_ids: appIds,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Copy app to another platform
|
// Copy app to another platform
|
||||||
copyApp: async (platform: string, appId: string, targetPlatform: string): Promise<{ new_id: string }> => {
|
copyApp: async (
|
||||||
|
platform: string,
|
||||||
|
appId: string,
|
||||||
|
targetPlatform: string,
|
||||||
|
): Promise<{ new_id: string }> => {
|
||||||
const response = await apiClient.post<{ new_id: string; target_platform: string }>(
|
const response = await apiClient.post<{ new_id: string; target_platform: string }>(
|
||||||
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`
|
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get branding
|
// Get branding
|
||||||
getBranding: async (): Promise<AppConfigBranding> => {
|
getBranding: async (): Promise<AppConfigBranding> => {
|
||||||
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding')
|
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update branding
|
// Update branding
|
||||||
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
|
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
|
||||||
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
|
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
|
||||||
branding,
|
branding,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get RemnaWave config status
|
// Get RemnaWave config status
|
||||||
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||||
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
|
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
|
||||||
'/cabinet/admin/apps/remnawave/status'
|
'/cabinet/admin/apps/remnawave/status',
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Set RemnaWave config UUID
|
// Set RemnaWave config UUID
|
||||||
setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
setRemnaWaveUuid: async (
|
||||||
|
uuid: string | null,
|
||||||
|
): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||||
const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>(
|
const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>(
|
||||||
'/cabinet/admin/apps/remnawave/uuid',
|
'/cabinet/admin/apps/remnawave/uuid',
|
||||||
{ uuid }
|
{ uuid },
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// List available RemnaWave configs
|
// List available RemnaWave configs
|
||||||
listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => {
|
listRemnaWaveConfigs: async (): Promise<
|
||||||
|
{ uuid: string; name: string; view_position: number }[]
|
||||||
|
> => {
|
||||||
const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>(
|
const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>(
|
||||||
'/cabinet/admin/apps/remnawave/configs'
|
'/cabinet/admin/apps/remnawave/configs',
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get RemnaWave subscription config
|
// Get RemnaWave subscription config
|
||||||
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
|
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
|
||||||
const response = await apiClient.get<{
|
const response = await apiClient.get<{
|
||||||
uuid: string
|
uuid: string;
|
||||||
name: string
|
name: string;
|
||||||
view_position: number
|
view_position: number;
|
||||||
config: RemnawaveConfig
|
config: RemnawaveConfig;
|
||||||
}>('/cabinet/admin/apps/remnawave/config')
|
}>('/cabinet/admin/apps/remnawave/config');
|
||||||
return response.data.config
|
return response.data.config;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// ============== RemnaWave Format Types ==============
|
// ============== RemnaWave Format Types ==============
|
||||||
|
|
||||||
export interface RemnawaveButton {
|
export interface RemnawaveButton {
|
||||||
url: string
|
url: string;
|
||||||
text: LocalizedText
|
text: LocalizedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveBlock {
|
export interface RemnawaveBlock {
|
||||||
title: LocalizedText
|
title: LocalizedText;
|
||||||
description: LocalizedText
|
description: LocalizedText;
|
||||||
buttons?: RemnawaveButton[]
|
buttons?: RemnawaveButton[];
|
||||||
svgIconKey?: string
|
svgIconKey?: string;
|
||||||
svgIconColor?: string
|
svgIconColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveApp {
|
export interface RemnawaveApp {
|
||||||
name: string
|
name: string;
|
||||||
featured?: boolean
|
featured?: boolean;
|
||||||
urlScheme?: string
|
urlScheme?: string;
|
||||||
isNeedBase64Encoding?: boolean
|
isNeedBase64Encoding?: boolean;
|
||||||
blocks: RemnawaveBlock[]
|
blocks: RemnawaveBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawavePlatform {
|
export interface RemnawavePlatform {
|
||||||
apps: RemnawaveApp[]
|
apps: RemnawaveApp[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveSvgItem {
|
export interface RemnawaveSvgItem {
|
||||||
svgString: string
|
svgString: string;
|
||||||
tags?: string[]
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveBaseSettings {
|
export interface RemnawaveBaseSettings {
|
||||||
isShowTutorialButton: boolean
|
isShowTutorialButton: boolean;
|
||||||
tutorialUrl: string
|
tutorialUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveBaseTranslations {
|
export interface RemnawaveBaseTranslations {
|
||||||
installApp: LocalizedText
|
installApp: LocalizedText;
|
||||||
addSubscription: LocalizedText
|
addSubscription: LocalizedText;
|
||||||
connectAndUse: LocalizedText
|
connectAndUse: LocalizedText;
|
||||||
copyLink: LocalizedText
|
copyLink: LocalizedText;
|
||||||
openApp: LocalizedText
|
openApp: LocalizedText;
|
||||||
tutorial: LocalizedText
|
tutorial: LocalizedText;
|
||||||
close: LocalizedText
|
close: LocalizedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveBrandingSettings {
|
export interface RemnawaveBrandingSettings {
|
||||||
name: string
|
name: string;
|
||||||
logoUrl: string
|
logoUrl: string;
|
||||||
supportUrl: string
|
supportUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnawaveConfig {
|
export interface RemnawaveConfig {
|
||||||
platforms: Record<string, RemnawavePlatform>
|
platforms: Record<string, RemnawavePlatform>;
|
||||||
svgLibrary?: Record<string, RemnawaveSvgItem>
|
svgLibrary?: Record<string, RemnawaveSvgItem>;
|
||||||
baseSettings?: RemnawaveBaseSettings
|
baseSettings?: RemnawaveBaseSettings;
|
||||||
baseTranslations?: RemnawaveBaseTranslations
|
baseTranslations?: RemnawaveBaseTranslations;
|
||||||
brandingSettings?: RemnawaveBrandingSettings
|
brandingSettings?: RemnawaveBrandingSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== Converter Functions ==============
|
// ============== Converter Functions ==============
|
||||||
@@ -225,23 +245,29 @@ const emptyLocalizedText = (): LocalizedText => ({
|
|||||||
zh: '',
|
zh: '',
|
||||||
fa: '',
|
fa: '',
|
||||||
fr: '',
|
fr: '',
|
||||||
})
|
});
|
||||||
|
|
||||||
// Convert Cabinet button to RemnaWave button
|
// Convert Cabinet button to RemnaWave button
|
||||||
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
|
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
|
||||||
url: button.buttonLink,
|
url: button.buttonLink,
|
||||||
text: { ...emptyLocalizedText(), ...button.buttonText },
|
text: { ...emptyLocalizedText(), ...button.buttonText },
|
||||||
})
|
});
|
||||||
|
|
||||||
// Convert RemnaWave button to Cabinet button
|
// Convert RemnaWave button to Cabinet button
|
||||||
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
|
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
|
||||||
buttonLink: button.url,
|
buttonLink: button.url,
|
||||||
buttonText: { en: button.text.en || '', ru: button.text.ru || '', zh: button.text.zh, fa: button.text.fa, fr: button.text.fr },
|
buttonText: {
|
||||||
})
|
en: button.text.en || '',
|
||||||
|
ru: button.text.ru || '',
|
||||||
|
zh: button.text.zh,
|
||||||
|
fa: button.text.fa,
|
||||||
|
fr: button.text.fr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Convert Cabinet app to RemnaWave app format
|
// Convert Cabinet app to RemnaWave app format
|
||||||
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||||
const blocks: RemnawaveBlock[] = []
|
const blocks: RemnawaveBlock[] = [];
|
||||||
|
|
||||||
// Block 1: Installation
|
// Block 1: Installation
|
||||||
blocks.push({
|
blocks.push({
|
||||||
@@ -250,17 +276,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
|||||||
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
|
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
|
||||||
svgIconKey: 'download',
|
svgIconKey: 'download',
|
||||||
svgIconColor: '#3B82F6',
|
svgIconColor: '#3B82F6',
|
||||||
})
|
});
|
||||||
|
|
||||||
// Block 2 (optional): Additional before subscription
|
// Block 2 (optional): Additional before subscription
|
||||||
if (app.additionalBeforeAddSubscriptionStep?.description?.en || app.additionalBeforeAddSubscriptionStep?.description?.ru) {
|
if (
|
||||||
|
app.additionalBeforeAddSubscriptionStep?.description?.en ||
|
||||||
|
app.additionalBeforeAddSubscriptionStep?.description?.ru
|
||||||
|
) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' },
|
title: app.additionalBeforeAddSubscriptionStep.title || {
|
||||||
description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description },
|
...emptyLocalizedText(),
|
||||||
|
en: 'Preparation',
|
||||||
|
ru: 'Подготовка',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
...emptyLocalizedText(),
|
||||||
|
...app.additionalBeforeAddSubscriptionStep.description,
|
||||||
|
},
|
||||||
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||||
svgIconKey: 'settings',
|
svgIconKey: 'settings',
|
||||||
svgIconColor: '#8B5CF6',
|
svgIconColor: '#8B5CF6',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block 3: Add subscription
|
// Block 3: Add subscription
|
||||||
@@ -270,17 +306,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
|||||||
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||||
svgIconKey: 'plus',
|
svgIconKey: 'plus',
|
||||||
svgIconColor: '#10B981',
|
svgIconColor: '#10B981',
|
||||||
})
|
});
|
||||||
|
|
||||||
// Block 4 (optional): Additional after subscription
|
// Block 4 (optional): Additional after subscription
|
||||||
if (app.additionalAfterAddSubscriptionStep?.description?.en || app.additionalAfterAddSubscriptionStep?.description?.ru) {
|
if (
|
||||||
|
app.additionalAfterAddSubscriptionStep?.description?.en ||
|
||||||
|
app.additionalAfterAddSubscriptionStep?.description?.ru
|
||||||
|
) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' },
|
title: app.additionalAfterAddSubscriptionStep.title || {
|
||||||
description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description },
|
...emptyLocalizedText(),
|
||||||
|
en: 'Configuration',
|
||||||
|
ru: 'Настройка',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
...emptyLocalizedText(),
|
||||||
|
...app.additionalAfterAddSubscriptionStep.description,
|
||||||
|
},
|
||||||
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||||
svgIconKey: 'settings',
|
svgIconKey: 'settings',
|
||||||
svgIconColor: '#F59E0B',
|
svgIconColor: '#F59E0B',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block 5: Connect and use
|
// Block 5: Connect and use
|
||||||
@@ -290,7 +336,7 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
|||||||
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
|
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
|
||||||
svgIconKey: 'check',
|
svgIconKey: 'check',
|
||||||
svgIconColor: '#22C55E',
|
svgIconColor: '#22C55E',
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: app.name,
|
name: app.name,
|
||||||
@@ -298,78 +344,104 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
|||||||
urlScheme: app.urlScheme,
|
urlScheme: app.urlScheme,
|
||||||
isNeedBase64Encoding: app.isNeedBase64Encoding,
|
isNeedBase64Encoding: app.isNeedBase64Encoding,
|
||||||
blocks,
|
blocks,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Convert RemnaWave app to Cabinet app format
|
// Convert RemnaWave app to Cabinet app format
|
||||||
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
|
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
|
||||||
const blocks = app.blocks || []
|
const blocks = app.blocks || [];
|
||||||
|
|
||||||
// Default empty step
|
// Default empty step
|
||||||
const emptyStep = (): AppStep => ({
|
const emptyStep = (): AppStep => ({
|
||||||
description: emptyLocalizedText(),
|
description: emptyLocalizedText(),
|
||||||
buttons: [],
|
buttons: [],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Try to identify blocks by their titles or position
|
// Try to identify blocks by their titles or position
|
||||||
let installationBlock: RemnawaveBlock | undefined
|
let installationBlock: RemnawaveBlock | undefined;
|
||||||
let subscriptionBlock: RemnawaveBlock | undefined
|
let subscriptionBlock: RemnawaveBlock | undefined;
|
||||||
let connectBlock: RemnawaveBlock | undefined
|
let connectBlock: RemnawaveBlock | undefined;
|
||||||
let beforeSubBlock: RemnawaveBlock | undefined
|
let beforeSubBlock: RemnawaveBlock | undefined;
|
||||||
let afterSubBlock: RemnawaveBlock | undefined
|
let afterSubBlock: RemnawaveBlock | undefined;
|
||||||
|
|
||||||
// First pass: try to identify by title keywords
|
// First pass: try to identify by title keywords
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const enTitle = (block.title?.en || '').toLowerCase()
|
const enTitle = (block.title?.en || '').toLowerCase();
|
||||||
const ruTitle = (block.title?.ru || '').toLowerCase()
|
const ruTitle = (block.title?.ru || '').toLowerCase();
|
||||||
|
|
||||||
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
|
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
|
||||||
if (!installationBlock) installationBlock = block
|
if (!installationBlock) installationBlock = block;
|
||||||
} else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) {
|
} else if (
|
||||||
if (!subscriptionBlock) subscriptionBlock = block
|
enTitle.includes('subscription') ||
|
||||||
} else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) {
|
enTitle.includes('add') ||
|
||||||
if (!connectBlock) connectBlock = block
|
ruTitle.includes('подписк') ||
|
||||||
|
ruTitle.includes('добав')
|
||||||
|
) {
|
||||||
|
if (!subscriptionBlock) subscriptionBlock = block;
|
||||||
|
} else if (
|
||||||
|
enTitle.includes('connect') ||
|
||||||
|
enTitle.includes('use') ||
|
||||||
|
ruTitle.includes('подключ') ||
|
||||||
|
ruTitle.includes('использ')
|
||||||
|
) {
|
||||||
|
if (!connectBlock) connectBlock = block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: assign remaining blocks by position if not found by title
|
// Second pass: assign remaining blocks by position if not found by title
|
||||||
if (!installationBlock && blocks.length > 0) {
|
if (!installationBlock && blocks.length > 0) {
|
||||||
installationBlock = blocks[0]
|
installationBlock = blocks[0];
|
||||||
}
|
}
|
||||||
if (!subscriptionBlock && blocks.length > 1) {
|
if (!subscriptionBlock && blocks.length > 1) {
|
||||||
subscriptionBlock = blocks.find(b => b !== installationBlock) || blocks[1]
|
subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1];
|
||||||
}
|
}
|
||||||
if (!connectBlock && blocks.length > 2) {
|
if (!connectBlock && blocks.length > 2) {
|
||||||
connectBlock = blocks.find(b => b !== installationBlock && b !== subscriptionBlock) || blocks[blocks.length - 1]
|
connectBlock =
|
||||||
|
blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) ||
|
||||||
|
blocks[blocks.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign additional blocks
|
// Assign additional blocks
|
||||||
const additionalBlocks = blocks.filter(b =>
|
const additionalBlocks = blocks.filter(
|
||||||
b !== installationBlock && b !== subscriptionBlock && b !== connectBlock
|
(b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock,
|
||||||
)
|
);
|
||||||
if (additionalBlocks.length >= 1) {
|
if (additionalBlocks.length >= 1) {
|
||||||
// Check if block appears before subscription
|
// Check if block appears before subscription
|
||||||
const subIndex = blocks.indexOf(subscriptionBlock!)
|
const subIndex = blocks.indexOf(subscriptionBlock!);
|
||||||
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0])
|
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]);
|
||||||
if (firstAdditionalIndex < subIndex) {
|
if (firstAdditionalIndex < subIndex) {
|
||||||
beforeSubBlock = additionalBlocks[0]
|
beforeSubBlock = additionalBlocks[0];
|
||||||
if (additionalBlocks.length >= 2) {
|
if (additionalBlocks.length >= 2) {
|
||||||
afterSubBlock = additionalBlocks[1]
|
afterSubBlock = additionalBlocks[1];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
afterSubBlock = additionalBlocks[0]
|
afterSubBlock = additionalBlocks[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert block to cabinet step
|
// Convert block to cabinet step
|
||||||
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
|
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
|
||||||
if (!block) return emptyStep()
|
if (!block) return emptyStep();
|
||||||
return {
|
return {
|
||||||
description: { en: block.description?.en || '', ru: block.description?.ru || '', zh: block.description?.zh, fa: block.description?.fa, fr: block.description?.fr },
|
description: {
|
||||||
title: block.title ? { en: block.title.en || '', ru: block.title.ru || '', zh: block.title.zh, fa: block.title.fa, fr: block.title.fr } : undefined,
|
en: block.description?.en || '',
|
||||||
|
ru: block.description?.ru || '',
|
||||||
|
zh: block.description?.zh,
|
||||||
|
fa: block.description?.fa,
|
||||||
|
fr: block.description?.fr,
|
||||||
|
},
|
||||||
|
title: block.title
|
||||||
|
? {
|
||||||
|
en: block.title.en || '',
|
||||||
|
ru: block.title.ru || '',
|
||||||
|
zh: block.title.zh,
|
||||||
|
fa: block.title.fa,
|
||||||
|
fr: block.title.fr,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
buttons: block.buttons?.map(remnawaveButtonToCabinet),
|
buttons: block.buttons?.map(remnawaveButtonToCabinet),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
|
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
|
||||||
@@ -382,66 +454,114 @@ export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppD
|
|||||||
connectAndUseStep: blockToStep(connectBlock),
|
connectAndUseStep: blockToStep(connectBlock),
|
||||||
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
|
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
|
||||||
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
|
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Export all apps from cabinet to RemnaWave format
|
// Export all apps from cabinet to RemnaWave format
|
||||||
export const exportToRemnawaveFormat = (
|
export const exportToRemnawaveFormat = (
|
||||||
platformApps: Record<string, AppDefinition[]>,
|
platformApps: Record<string, AppDefinition[]>,
|
||||||
branding?: AppConfigBranding
|
branding?: AppConfigBranding,
|
||||||
): RemnawaveConfig => {
|
): RemnawaveConfig => {
|
||||||
const platforms: Record<string, RemnawavePlatform> = {}
|
const platforms: Record<string, RemnawavePlatform> = {};
|
||||||
|
|
||||||
for (const [platform, apps] of Object.entries(platformApps)) {
|
for (const [platform, apps] of Object.entries(platformApps)) {
|
||||||
platforms[platform] = {
|
platforms[platform] = {
|
||||||
apps: apps.map(cabinetAppToRemnawave),
|
apps: apps.map(cabinetAppToRemnawave),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
platforms,
|
platforms,
|
||||||
svgLibrary: {
|
svgLibrary: {
|
||||||
download: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>' },
|
download: {
|
||||||
plus: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' },
|
svgString:
|
||||||
check: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>' },
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>',
|
||||||
settings: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>' },
|
},
|
||||||
|
plus: {
|
||||||
|
svgString:
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>',
|
||||||
|
},
|
||||||
|
check: {
|
||||||
|
svgString:
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
svgString:
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
baseSettings: {
|
baseSettings: {
|
||||||
isShowTutorialButton: false,
|
isShowTutorialButton: false,
|
||||||
tutorialUrl: '',
|
tutorialUrl: '',
|
||||||
},
|
},
|
||||||
baseTranslations: {
|
baseTranslations: {
|
||||||
installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', fa: 'نصب برنامه', fr: 'Installer l\'application' },
|
installApp: {
|
||||||
addSubscription: { en: 'Add Subscription', ru: 'Добавить подписку', zh: '添加订阅', fa: 'اضافه کردن اشتراک', fr: 'Ajouter un abonnement' },
|
en: 'Install App',
|
||||||
connectAndUse: { en: 'Connect and Use', ru: 'Подключиться и использовать', zh: '连接并使用', fa: 'اتصال و استفاده', fr: 'Connecter et utiliser' },
|
ru: 'Установить приложение',
|
||||||
copyLink: { en: 'Copy Link', ru: 'Скопировать ссылку', zh: '复制链接', fa: 'کپی لینک', fr: 'Copier le lien' },
|
zh: '安装应用',
|
||||||
openApp: { en: 'Open App', ru: 'Открыть приложение', zh: '打开应用', fa: 'باز کردن برنامه', fr: 'Ouvrir l\'application' },
|
fa: 'نصب برنامه',
|
||||||
|
fr: "Installer l'application",
|
||||||
|
},
|
||||||
|
addSubscription: {
|
||||||
|
en: 'Add Subscription',
|
||||||
|
ru: 'Добавить подписку',
|
||||||
|
zh: '添加订阅',
|
||||||
|
fa: 'اضافه کردن اشتراک',
|
||||||
|
fr: 'Ajouter un abonnement',
|
||||||
|
},
|
||||||
|
connectAndUse: {
|
||||||
|
en: 'Connect and Use',
|
||||||
|
ru: 'Подключиться и использовать',
|
||||||
|
zh: '连接并使用',
|
||||||
|
fa: 'اتصال و استفاده',
|
||||||
|
fr: 'Connecter et utiliser',
|
||||||
|
},
|
||||||
|
copyLink: {
|
||||||
|
en: 'Copy Link',
|
||||||
|
ru: 'Скопировать ссылку',
|
||||||
|
zh: '复制链接',
|
||||||
|
fa: 'کپی لینک',
|
||||||
|
fr: 'Copier le lien',
|
||||||
|
},
|
||||||
|
openApp: {
|
||||||
|
en: 'Open App',
|
||||||
|
ru: 'Открыть приложение',
|
||||||
|
zh: '打开应用',
|
||||||
|
fa: 'باز کردن برنامه',
|
||||||
|
fr: "Ouvrir l'application",
|
||||||
|
},
|
||||||
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
|
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
|
||||||
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
|
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
|
||||||
},
|
},
|
||||||
brandingSettings: branding ? {
|
brandingSettings: branding
|
||||||
|
? {
|
||||||
name: branding.name,
|
name: branding.name,
|
||||||
logoUrl: branding.logoUrl,
|
logoUrl: branding.logoUrl,
|
||||||
supportUrl: branding.supportUrl,
|
supportUrl: branding.supportUrl,
|
||||||
} : undefined,
|
|
||||||
}
|
}
|
||||||
}
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Import apps from RemnaWave format to cabinet
|
// Import apps from RemnaWave format to cabinet
|
||||||
export const importFromRemnawaveFormat = (
|
export const importFromRemnawaveFormat = (
|
||||||
config: RemnawaveConfig
|
config: RemnawaveConfig,
|
||||||
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
|
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
|
||||||
const platformApps: Record<string, AppDefinition[]> = {}
|
const platformApps: Record<string, AppDefinition[]> = {};
|
||||||
|
|
||||||
for (const [platform, platformData] of Object.entries(config.platforms || {})) {
|
for (const [platform, platformData] of Object.entries(config.platforms || {})) {
|
||||||
platformApps[platform] = (platformData.apps || []).map(app => remnawaveAppToCabinet(app, platform))
|
platformApps[platform] = (platformData.apps || []).map((app) =>
|
||||||
|
remnawaveAppToCabinet(app, platform),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const branding = config.brandingSettings ? {
|
const branding = config.brandingSettings
|
||||||
|
? {
|
||||||
name: config.brandingSettings.name,
|
name: config.brandingSettings.name,
|
||||||
logoUrl: config.brandingSettings.logoUrl,
|
logoUrl: config.brandingSettings.logoUrl,
|
||||||
supportUrl: config.brandingSettings.supportUrl,
|
supportUrl: config.brandingSettings.supportUrl,
|
||||||
} : undefined
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return { platformApps, branding }
|
return { platformApps, branding };
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,168 +1,187 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface BroadcastFilter {
|
export interface BroadcastFilter {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
count: number | null
|
count: number | null;
|
||||||
group: string | null
|
group: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffFilter {
|
export interface TariffFilter {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
tariff_id: number
|
tariff_id: number;
|
||||||
count: number
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastFiltersResponse {
|
export interface BroadcastFiltersResponse {
|
||||||
filters: BroadcastFilter[]
|
filters: BroadcastFilter[];
|
||||||
tariff_filters: TariffFilter[]
|
tariff_filters: TariffFilter[];
|
||||||
custom_filters: BroadcastFilter[]
|
custom_filters: BroadcastFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffForBroadcast {
|
export interface TariffForBroadcast {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
filter_key: string
|
filter_key: string;
|
||||||
active_users_count: number
|
active_users_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastTariffsResponse {
|
export interface BroadcastTariffsResponse {
|
||||||
tariffs: TariffForBroadcast[]
|
tariffs: TariffForBroadcast[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastButton {
|
export interface BroadcastButton {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
default: boolean
|
default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastButtonsResponse {
|
export interface BroadcastButtonsResponse {
|
||||||
buttons: BroadcastButton[]
|
buttons: BroadcastButton[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastMedia {
|
export interface BroadcastMedia {
|
||||||
type: 'photo' | 'video' | 'document'
|
type: 'photo' | 'video' | 'document';
|
||||||
file_id: string
|
file_id: string;
|
||||||
caption?: string
|
caption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastCreateRequest {
|
export interface BroadcastCreateRequest {
|
||||||
target: string
|
target: string;
|
||||||
message_text: string
|
message_text: string;
|
||||||
selected_buttons: string[]
|
selected_buttons: string[];
|
||||||
media?: BroadcastMedia
|
media?: BroadcastMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Broadcast {
|
export interface Broadcast {
|
||||||
id: number
|
id: number;
|
||||||
target_type: string
|
target_type: string;
|
||||||
message_text: string
|
message_text: string;
|
||||||
has_media: boolean
|
has_media: boolean;
|
||||||
media_type: string | null
|
media_type: string | null;
|
||||||
media_file_id: string | null
|
media_file_id: string | null;
|
||||||
media_caption: string | null
|
media_caption: string | null;
|
||||||
total_count: number
|
total_count: number;
|
||||||
sent_count: number
|
sent_count: number;
|
||||||
failed_count: number
|
failed_count: number;
|
||||||
status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling'
|
status:
|
||||||
admin_id: number | null
|
| 'queued'
|
||||||
admin_name: string | null
|
| 'in_progress'
|
||||||
created_at: string
|
| 'completed'
|
||||||
completed_at: string | null
|
| 'partial'
|
||||||
progress_percent: number
|
| 'failed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'cancelling';
|
||||||
|
admin_id: number | null;
|
||||||
|
admin_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
progress_percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastListResponse {
|
export interface BroadcastListResponse {
|
||||||
items: Broadcast[]
|
items: Broadcast[];
|
||||||
total: number
|
total: number;
|
||||||
limit: number
|
limit: number;
|
||||||
offset: number
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastPreviewRequest {
|
export interface BroadcastPreviewRequest {
|
||||||
target: string
|
target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastPreviewResponse {
|
export interface BroadcastPreviewResponse {
|
||||||
target: string
|
target: string;
|
||||||
count: number
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaUploadResponse {
|
export interface MediaUploadResponse {
|
||||||
media_type: string
|
media_type: string;
|
||||||
file_id: string
|
file_id: string;
|
||||||
file_unique_id: string | null
|
file_unique_id: string | null;
|
||||||
media_url: string
|
media_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminBroadcastsApi = {
|
export const adminBroadcastsApi = {
|
||||||
// Get all available filters with counts
|
// Get all available filters with counts
|
||||||
getFilters: async (): Promise<BroadcastFiltersResponse> => {
|
getFilters: async (): Promise<BroadcastFiltersResponse> => {
|
||||||
const response = await apiClient.get<BroadcastFiltersResponse>('/cabinet/admin/broadcasts/filters')
|
const response = await apiClient.get<BroadcastFiltersResponse>(
|
||||||
return response.data
|
'/cabinet/admin/broadcasts/filters',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get tariffs for filtering
|
// Get tariffs for filtering
|
||||||
getTariffs: async (): Promise<BroadcastTariffsResponse> => {
|
getTariffs: async (): Promise<BroadcastTariffsResponse> => {
|
||||||
const response = await apiClient.get<BroadcastTariffsResponse>('/cabinet/admin/broadcasts/tariffs')
|
const response = await apiClient.get<BroadcastTariffsResponse>(
|
||||||
return response.data
|
'/cabinet/admin/broadcasts/tariffs',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available buttons
|
// Get available buttons
|
||||||
getButtons: async (): Promise<BroadcastButtonsResponse> => {
|
getButtons: async (): Promise<BroadcastButtonsResponse> => {
|
||||||
const response = await apiClient.get<BroadcastButtonsResponse>('/cabinet/admin/broadcasts/buttons')
|
const response = await apiClient.get<BroadcastButtonsResponse>(
|
||||||
return response.data
|
'/cabinet/admin/broadcasts/buttons',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Preview broadcast (get recipients count)
|
// Preview broadcast (get recipients count)
|
||||||
preview: async (target: string): Promise<BroadcastPreviewResponse> => {
|
preview: async (target: string): Promise<BroadcastPreviewResponse> => {
|
||||||
const response = await apiClient.post<BroadcastPreviewResponse>('/cabinet/admin/broadcasts/preview', {
|
const response = await apiClient.post<BroadcastPreviewResponse>(
|
||||||
|
'/cabinet/admin/broadcasts/preview',
|
||||||
|
{
|
||||||
target,
|
target,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create and start broadcast
|
// Create and start broadcast
|
||||||
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
|
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
|
||||||
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data)
|
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get list of broadcasts
|
// Get list of broadcasts
|
||||||
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
|
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
|
||||||
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
|
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
|
||||||
params: { limit, offset },
|
params: { limit, offset },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get broadcast details
|
// Get broadcast details
|
||||||
get: async (id: number): Promise<Broadcast> => {
|
get: async (id: number): Promise<Broadcast> => {
|
||||||
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`)
|
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stop broadcast
|
// Stop broadcast
|
||||||
stop: async (id: number): Promise<Broadcast> => {
|
stop: async (id: number): Promise<Broadcast> => {
|
||||||
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`)
|
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Upload media (uses existing media endpoint)
|
// Upload media (uses existing media endpoint)
|
||||||
uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise<MediaUploadResponse> => {
|
uploadMedia: async (
|
||||||
const formData = new FormData()
|
file: File,
|
||||||
formData.append('file', file)
|
mediaType: 'photo' | 'video' | 'document',
|
||||||
formData.append('media_type', mediaType)
|
): Promise<MediaUploadResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('media_type', mediaType);
|
||||||
|
|
||||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default adminBroadcastsApi
|
export default adminBroadcastsApi;
|
||||||
|
|||||||
@@ -1,85 +1,105 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface EmailTemplateLanguageStatus {
|
export interface EmailTemplateLanguageStatus {
|
||||||
has_custom: boolean
|
has_custom: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplateType {
|
export interface EmailTemplateType {
|
||||||
type: string
|
type: string;
|
||||||
label: Record<string, string>
|
label: Record<string, string>;
|
||||||
description: Record<string, string>
|
description: Record<string, string>;
|
||||||
context_vars: string[]
|
context_vars: string[];
|
||||||
languages: Record<string, EmailTemplateLanguageStatus>
|
languages: Record<string, EmailTemplateLanguageStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplateListResponse {
|
export interface EmailTemplateListResponse {
|
||||||
items: EmailTemplateType[]
|
items: EmailTemplateType[];
|
||||||
available_languages: string[]
|
available_languages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplateLanguageData {
|
export interface EmailTemplateLanguageData {
|
||||||
subject: string
|
subject: string;
|
||||||
body_html: string
|
body_html: string;
|
||||||
is_default: boolean
|
is_default: boolean;
|
||||||
default_subject: string
|
default_subject: string;
|
||||||
default_body_html: string
|
default_body_html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplateDetail {
|
export interface EmailTemplateDetail {
|
||||||
notification_type: string
|
notification_type: string;
|
||||||
label: Record<string, string>
|
label: Record<string, string>;
|
||||||
description: Record<string, string>
|
description: Record<string, string>;
|
||||||
context_vars: string[]
|
context_vars: string[];
|
||||||
languages: Record<string, EmailTemplateLanguageData>
|
languages: Record<string, EmailTemplateLanguageData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplateUpdateRequest {
|
export interface EmailTemplateUpdateRequest {
|
||||||
subject: string
|
subject: string;
|
||||||
body_html: string
|
body_html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplatePreviewRequest {
|
export interface EmailTemplatePreviewRequest {
|
||||||
language: string
|
language: string;
|
||||||
subject?: string
|
subject?: string;
|
||||||
body_html?: string
|
body_html?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplatePreviewResponse {
|
export interface EmailTemplatePreviewResponse {
|
||||||
subject: string
|
subject: string;
|
||||||
body_html: string
|
body_html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailTemplateSendTestRequest {
|
export interface EmailTemplateSendTestRequest {
|
||||||
language: string
|
language: string;
|
||||||
email?: string
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminEmailTemplatesApi = {
|
export const adminEmailTemplatesApi = {
|
||||||
getTemplateTypes: async (): Promise<EmailTemplateListResponse> => {
|
getTemplateTypes: async (): Promise<EmailTemplateListResponse> => {
|
||||||
const response = await apiClient.get<EmailTemplateListResponse>('/cabinet/admin/email-templates')
|
const response = await apiClient.get<EmailTemplateListResponse>(
|
||||||
return response.data
|
'/cabinet/admin/email-templates',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTemplate: async (notificationType: string): Promise<EmailTemplateDetail> => {
|
getTemplate: async (notificationType: string): Promise<EmailTemplateDetail> => {
|
||||||
const response = await apiClient.get<EmailTemplateDetail>(`/cabinet/admin/email-templates/${notificationType}`)
|
const response = await apiClient.get<EmailTemplateDetail>(
|
||||||
return response.data
|
`/cabinet/admin/email-templates/${notificationType}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTemplate: async (notificationType: string, language: string, data: EmailTemplateUpdateRequest): Promise<void> => {
|
updateTemplate: async (
|
||||||
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data)
|
notificationType: string,
|
||||||
|
language: string,
|
||||||
|
data: EmailTemplateUpdateRequest,
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTemplate: async (notificationType: string, language: string): Promise<void> => {
|
deleteTemplate: async (notificationType: string, language: string): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`)
|
await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
previewTemplate: async (notificationType: string, data: EmailTemplatePreviewRequest): Promise<EmailTemplatePreviewResponse> => {
|
previewTemplate: async (
|
||||||
const response = await apiClient.post<EmailTemplatePreviewResponse>(`/cabinet/admin/email-templates/${notificationType}/preview`, data)
|
notificationType: string,
|
||||||
return response.data
|
data: EmailTemplatePreviewRequest,
|
||||||
|
): Promise<EmailTemplatePreviewResponse> => {
|
||||||
|
const response = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||||
|
`/cabinet/admin/email-templates/${notificationType}/preview`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
sendTestEmail: async (notificationType: string, data: EmailTemplateSendTestRequest): Promise<{ sent_to: string }> => {
|
sendTestEmail: async (
|
||||||
const response = await apiClient.post<{ status: string; sent_to: string }>(`/cabinet/admin/email-templates/${notificationType}/test`, data)
|
notificationType: string,
|
||||||
return response.data
|
data: EmailTemplateSendTestRequest,
|
||||||
|
): Promise<{ sent_to: string }> => {
|
||||||
|
const response = await apiClient.post<{ status: string; sent_to: string }>(
|
||||||
|
`/cabinet/admin/email-templates/${notificationType}/test`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types'
|
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
||||||
|
|
||||||
export const adminPaymentMethodsApi = {
|
export const adminPaymentMethodsApi = {
|
||||||
getAll: async (): Promise<PaymentMethodConfig[]> => {
|
getAll: async (): Promise<PaymentMethodConfig[]> => {
|
||||||
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods')
|
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getOne: async (methodId: string): Promise<PaymentMethodConfig> => {
|
getOne: async (methodId: string): Promise<PaymentMethodConfig> => {
|
||||||
const response = await apiClient.get<PaymentMethodConfig>(`/cabinet/admin/payment-methods/${methodId}`)
|
const response = await apiClient.get<PaymentMethodConfig>(
|
||||||
return response.data
|
`/cabinet/admin/payment-methods/${methodId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => {
|
update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => {
|
||||||
const response = await apiClient.put<PaymentMethodConfig>(
|
const response = await apiClient.put<PaymentMethodConfig>(
|
||||||
`/cabinet/admin/payment-methods/${methodId}`,
|
`/cabinet/admin/payment-methods/${methodId}`,
|
||||||
data
|
data,
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateOrder: async (methodIds: string[]): Promise<void> => {
|
updateOrder: async (methodIds: string[]): Promise<void> => {
|
||||||
await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds })
|
await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds });
|
||||||
},
|
},
|
||||||
|
|
||||||
getPromoGroups: async (): Promise<PromoGroupSimple[]> => {
|
getPromoGroups: async (): Promise<PromoGroupSimple[]> => {
|
||||||
const response = await apiClient.get<PromoGroupSimple[]>('/cabinet/admin/payment-methods/promo-groups')
|
const response = await apiClient.get<PromoGroupSimple[]>(
|
||||||
return response.data
|
'/cabinet/admin/payment-methods/promo-groups',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,39 +1,46 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types';
|
||||||
|
|
||||||
export interface PaymentsStats {
|
export interface PaymentsStats {
|
||||||
total_pending: number
|
total_pending: number;
|
||||||
by_method: Record<string, number>
|
by_method: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminPaymentsApi = {
|
export const adminPaymentsApi = {
|
||||||
// Get all pending payments (admin)
|
// Get all pending payments (admin)
|
||||||
getPendingPayments: async (params?: {
|
getPendingPayments: async (params?: {
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
method_filter?: string
|
method_filter?: string;
|
||||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/admin/payments', {
|
const response = await apiClient.get<PaginatedResponse<PendingPayment>>(
|
||||||
|
'/cabinet/admin/payments',
|
||||||
|
{
|
||||||
params,
|
params,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get payments statistics
|
// Get payments statistics
|
||||||
getStats: async (): Promise<PaymentsStats> => {
|
getStats: async (): Promise<PaymentsStats> => {
|
||||||
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats')
|
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get specific payment details
|
// Get specific payment details
|
||||||
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||||
const response = await apiClient.get<PendingPayment>(`/cabinet/admin/payments/${method}/${paymentId}`)
|
const response = await apiClient.get<PendingPayment>(
|
||||||
return response.data
|
`/cabinet/admin/payments/${method}/${paymentId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Manually check payment status
|
// Manually check payment status
|
||||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/admin/payments/${method}/${paymentId}/check`)
|
const response = await apiClient.post<ManualCheckResponse>(
|
||||||
return response.data
|
`/cabinet/admin/payments/${method}/${paymentId}/check`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,227 +1,227 @@
|
|||||||
import { apiClient } from './client'
|
import { apiClient } from './client';
|
||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
|
|
||||||
// Status & Connection
|
// Status & Connection
|
||||||
export interface ConnectionStatus {
|
export interface ConnectionStatus {
|
||||||
status: string
|
status: string;
|
||||||
message: string
|
message: string;
|
||||||
api_url?: string
|
api_url?: string;
|
||||||
status_code?: number
|
status_code?: number;
|
||||||
system_info?: Record<string, unknown>
|
system_info?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemnaWaveStatusResponse {
|
export interface RemnaWaveStatusResponse {
|
||||||
is_configured: boolean
|
is_configured: boolean;
|
||||||
configuration_error?: string
|
configuration_error?: string;
|
||||||
connection?: ConnectionStatus
|
connection?: ConnectionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// System Statistics
|
// System Statistics
|
||||||
export interface SystemSummary {
|
export interface SystemSummary {
|
||||||
users_online: number
|
users_online: number;
|
||||||
total_users: number
|
total_users: number;
|
||||||
active_connections: number
|
active_connections: number;
|
||||||
nodes_online: number
|
nodes_online: number;
|
||||||
users_last_day: number
|
users_last_day: number;
|
||||||
users_last_week: number
|
users_last_week: number;
|
||||||
users_never_online: number
|
users_never_online: number;
|
||||||
total_user_traffic: number
|
total_user_traffic: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
cpu_cores: number
|
cpu_cores: number;
|
||||||
cpu_physical_cores: number
|
cpu_physical_cores: number;
|
||||||
memory_total: number
|
memory_total: number;
|
||||||
memory_used: number
|
memory_used: number;
|
||||||
memory_free: number
|
memory_free: number;
|
||||||
memory_available: number
|
memory_available: number;
|
||||||
uptime_seconds: number
|
uptime_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Bandwidth {
|
export interface Bandwidth {
|
||||||
realtime_download: number
|
realtime_download: number;
|
||||||
realtime_upload: number
|
realtime_upload: number;
|
||||||
realtime_total: number
|
realtime_total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrafficPeriod {
|
export interface TrafficPeriod {
|
||||||
current: number
|
current: number;
|
||||||
previous: number
|
previous: number;
|
||||||
difference?: string
|
difference?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrafficPeriods {
|
export interface TrafficPeriods {
|
||||||
last_2_days: TrafficPeriod
|
last_2_days: TrafficPeriod;
|
||||||
last_7_days: TrafficPeriod
|
last_7_days: TrafficPeriod;
|
||||||
last_30_days: TrafficPeriod
|
last_30_days: TrafficPeriod;
|
||||||
current_month: TrafficPeriod
|
current_month: TrafficPeriod;
|
||||||
current_year: TrafficPeriod
|
current_year: TrafficPeriod;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStatsResponse {
|
export interface SystemStatsResponse {
|
||||||
system: SystemSummary
|
system: SystemSummary;
|
||||||
users_by_status: Record<string, number>
|
users_by_status: Record<string, number>;
|
||||||
server_info: ServerInfo
|
server_info: ServerInfo;
|
||||||
bandwidth: Bandwidth
|
bandwidth: Bandwidth;
|
||||||
traffic_periods: TrafficPeriods
|
traffic_periods: TrafficPeriods;
|
||||||
nodes_realtime: Record<string, unknown>[]
|
nodes_realtime: Record<string, unknown>[];
|
||||||
nodes_weekly: Record<string, unknown>[]
|
nodes_weekly: Record<string, unknown>[];
|
||||||
last_updated?: string
|
last_updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nodes
|
// Nodes
|
||||||
export interface NodeInfo {
|
export interface NodeInfo {
|
||||||
uuid: string
|
uuid: string;
|
||||||
name: string
|
name: string;
|
||||||
address: string
|
address: string;
|
||||||
country_code?: string
|
country_code?: string;
|
||||||
is_connected: boolean
|
is_connected: boolean;
|
||||||
is_disabled: boolean
|
is_disabled: boolean;
|
||||||
is_node_online: boolean
|
is_node_online: boolean;
|
||||||
is_xray_running: boolean
|
is_xray_running: boolean;
|
||||||
users_online?: number
|
users_online?: number;
|
||||||
traffic_used_bytes?: number
|
traffic_used_bytes?: number;
|
||||||
traffic_limit_bytes?: number
|
traffic_limit_bytes?: number;
|
||||||
last_status_change?: string
|
last_status_change?: string;
|
||||||
last_status_message?: string
|
last_status_message?: string;
|
||||||
xray_uptime?: string
|
xray_uptime?: string;
|
||||||
is_traffic_tracking_active: boolean
|
is_traffic_tracking_active: boolean;
|
||||||
traffic_reset_day?: number
|
traffic_reset_day?: number;
|
||||||
notify_percent?: number
|
notify_percent?: number;
|
||||||
consumption_multiplier: number
|
consumption_multiplier: number;
|
||||||
cpu_count?: number
|
cpu_count?: number;
|
||||||
cpu_model?: string
|
cpu_model?: string;
|
||||||
total_ram?: string
|
total_ram?: string;
|
||||||
created_at?: string
|
created_at?: string;
|
||||||
updated_at?: string
|
updated_at?: string;
|
||||||
provider_uuid?: string
|
provider_uuid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodesListResponse {
|
export interface NodesListResponse {
|
||||||
items: NodeInfo[]
|
items: NodeInfo[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodesOverview {
|
export interface NodesOverview {
|
||||||
total: number
|
total: number;
|
||||||
online: number
|
online: number;
|
||||||
offline: number
|
offline: number;
|
||||||
disabled: number
|
disabled: number;
|
||||||
total_users_online: number
|
total_users_online: number;
|
||||||
nodes: NodeInfo[]
|
nodes: NodeInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeStatisticsResponse {
|
export interface NodeStatisticsResponse {
|
||||||
node: NodeInfo
|
node: NodeInfo;
|
||||||
realtime?: Record<string, unknown>
|
realtime?: Record<string, unknown>;
|
||||||
usage_history: Record<string, unknown>[]
|
usage_history: Record<string, unknown>[];
|
||||||
last_updated?: string
|
last_updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeActionResponse {
|
export interface NodeActionResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message?: string
|
message?: string;
|
||||||
is_disabled?: boolean
|
is_disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Squads
|
// Squads
|
||||||
export interface SquadWithLocalInfo {
|
export interface SquadWithLocalInfo {
|
||||||
uuid: string
|
uuid: string;
|
||||||
name: string
|
name: string;
|
||||||
members_count: number
|
members_count: number;
|
||||||
inbounds_count: number
|
inbounds_count: number;
|
||||||
inbounds: Record<string, unknown>[]
|
inbounds: Record<string, unknown>[];
|
||||||
local_id?: number
|
local_id?: number;
|
||||||
display_name?: string
|
display_name?: string;
|
||||||
country_code?: string
|
country_code?: string;
|
||||||
is_available?: boolean
|
is_available?: boolean;
|
||||||
is_trial_eligible?: boolean
|
is_trial_eligible?: boolean;
|
||||||
price_kopeks?: number
|
price_kopeks?: number;
|
||||||
max_users?: number
|
max_users?: number;
|
||||||
current_users?: number
|
current_users?: number;
|
||||||
is_synced: boolean
|
is_synced: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SquadsListResponse {
|
export interface SquadsListResponse {
|
||||||
items: SquadWithLocalInfo[]
|
items: SquadWithLocalInfo[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SquadDetailResponse extends SquadWithLocalInfo {
|
export interface SquadDetailResponse extends SquadWithLocalInfo {
|
||||||
description?: string
|
description?: string;
|
||||||
sort_order?: number
|
sort_order?: number;
|
||||||
active_subscriptions: number
|
active_subscriptions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SquadOperationResponse {
|
export interface SquadOperationResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message?: string
|
message?: string;
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration
|
// Migration
|
||||||
export interface MigrationPreviewResponse {
|
export interface MigrationPreviewResponse {
|
||||||
squad_uuid: string
|
squad_uuid: string;
|
||||||
squad_name: string
|
squad_name: string;
|
||||||
current_users: number
|
current_users: number;
|
||||||
max_users?: number
|
max_users?: number;
|
||||||
users_to_migrate: number
|
users_to_migrate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrationStats {
|
export interface MigrationStats {
|
||||||
source_uuid: string
|
source_uuid: string;
|
||||||
target_uuid: string
|
target_uuid: string;
|
||||||
total: number
|
total: number;
|
||||||
updated: number
|
updated: number;
|
||||||
panel_updated: number
|
panel_updated: number;
|
||||||
panel_failed: number
|
panel_failed: number;
|
||||||
source_removed: number
|
source_removed: number;
|
||||||
target_added: number
|
target_added: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrationResponse {
|
export interface MigrationResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message?: string
|
message?: string;
|
||||||
error?: string
|
error?: string;
|
||||||
data?: MigrationStats
|
data?: MigrationStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inbounds
|
// Inbounds
|
||||||
export interface InboundsListResponse {
|
export interface InboundsListResponse {
|
||||||
items: Record<string, unknown>[]
|
items: Record<string, unknown>[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto Sync
|
// Auto Sync
|
||||||
export interface AutoSyncStatus {
|
export interface AutoSyncStatus {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
times: string[]
|
times: string[];
|
||||||
next_run?: string
|
next_run?: string;
|
||||||
is_running: boolean
|
is_running: boolean;
|
||||||
last_run_started_at?: string
|
last_run_started_at?: string;
|
||||||
last_run_finished_at?: string
|
last_run_finished_at?: string;
|
||||||
last_run_success?: boolean
|
last_run_success?: boolean;
|
||||||
last_run_reason?: string
|
last_run_reason?: string;
|
||||||
last_run_error?: string
|
last_run_error?: string;
|
||||||
last_user_stats?: Record<string, unknown>
|
last_user_stats?: Record<string, unknown>;
|
||||||
last_server_stats?: Record<string, unknown>
|
last_server_stats?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoSyncRunResponse {
|
export interface AutoSyncRunResponse {
|
||||||
started: boolean
|
started: boolean;
|
||||||
success?: boolean
|
success?: boolean;
|
||||||
error?: string
|
error?: string;
|
||||||
user_stats?: Record<string, unknown>
|
user_stats?: Record<string, unknown>;
|
||||||
server_stats?: Record<string, unknown>
|
server_stats?: Record<string, unknown>;
|
||||||
reason?: string
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync
|
// Sync
|
||||||
export interface SyncResponse {
|
export interface SyncResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message?: string
|
message?: string;
|
||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ API ============
|
// ============ API ============
|
||||||
@@ -229,158 +229,176 @@ export interface SyncResponse {
|
|||||||
export const adminRemnawaveApi = {
|
export const adminRemnawaveApi = {
|
||||||
// Status & Connection
|
// Status & Connection
|
||||||
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/status')
|
const response = await apiClient.get('/cabinet/admin/remnawave/status');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// System Statistics
|
// System Statistics
|
||||||
getSystemStats: async (): Promise<SystemStatsResponse> => {
|
getSystemStats: async (): Promise<SystemStatsResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/system')
|
const response = await apiClient.get('/cabinet/admin/remnawave/system');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Nodes
|
// Nodes
|
||||||
getNodes: async (): Promise<NodesListResponse> => {
|
getNodes: async (): Promise<NodesListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes')
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodesOverview: async (): Promise<NodesOverview> => {
|
getNodesOverview: async (): Promise<NodesOverview> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview')
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime')
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getNode: async (uuid: string): Promise<NodeInfo> => {
|
getNode: async (uuid: string): Promise<NodeInfo> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`)
|
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
|
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`)
|
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise<NodeActionResponse> => {
|
nodeAction: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action })
|
uuid: string,
|
||||||
return response.data
|
action: 'enable' | 'disable' | 'restart',
|
||||||
|
): Promise<NodeActionResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, {
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all')
|
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Squads
|
// Squads
|
||||||
getSquads: async (): Promise<SquadsListResponse> => {
|
getSquads: async (): Promise<SquadsListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/squads')
|
const response = await apiClient.get('/cabinet/admin/remnawave/squads');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
|
getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`)
|
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
createSquad: async (data: {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data)
|
name: string;
|
||||||
return response.data
|
inbound_uuids?: string[];
|
||||||
|
}): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
updateSquad: async (
|
||||||
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data)
|
uuid: string,
|
||||||
return response.data
|
data: { name?: string; inbound_uuids?: string[] },
|
||||||
|
): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
|
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
|
||||||
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`)
|
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
squadAction: async (uuid: string, data: {
|
squadAction: async (
|
||||||
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds'
|
uuid: string,
|
||||||
name?: string
|
data: {
|
||||||
inbound_uuids?: string[]
|
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds';
|
||||||
}): Promise<SquadOperationResponse> => {
|
name?: string;
|
||||||
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data)
|
inbound_uuids?: string[];
|
||||||
return response.data
|
},
|
||||||
|
): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Migration
|
// Migration
|
||||||
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
|
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`)
|
const response = await apiClient.get(
|
||||||
return response.data
|
`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
|
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
|
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
|
||||||
source_uuid: sourceUuid,
|
source_uuid: sourceUuid,
|
||||||
target_uuid: targetUuid,
|
target_uuid: targetUuid,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Inbounds
|
// Inbounds
|
||||||
getInbounds: async (): Promise<InboundsListResponse> => {
|
getInbounds: async (): Promise<InboundsListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds')
|
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auto Sync
|
// Auto Sync
|
||||||
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
|
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status')
|
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
|
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled })
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
runAutoSync: async (): Promise<AutoSyncRunResponse> => {
|
runAutoSync: async (): Promise<AutoSyncRunResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run')
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Manual Sync
|
// Manual Sync
|
||||||
syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise<SyncResponse> => {
|
syncFromPanel: async (
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode })
|
mode: 'all' | 'new_only' | 'update_only' = 'all',
|
||||||
return response.data
|
): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode });
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
syncToPanel: async (): Promise<SyncResponse> => {
|
syncToPanel: async (): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel')
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
syncServers: async (): Promise<SyncResponse> => {
|
syncServers: async (): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers')
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
validateSubscriptions: async (): Promise<SyncResponse> => {
|
validateSubscriptions: async (): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate')
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
cleanupSubscriptions: async (): Promise<SyncResponse> => {
|
cleanupSubscriptions: async (): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup')
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
|
syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses')
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getSyncRecommendations: async (): Promise<SyncResponse> => {
|
getSyncRecommendations: async (): Promise<SyncResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations')
|
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default adminRemnawaveApi
|
export default adminRemnawaveApi;
|
||||||
|
|||||||
@@ -1,73 +1,79 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface SettingCategoryRef {
|
export interface SettingCategoryRef {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingCategorySummary {
|
export interface SettingCategorySummary {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
description: string
|
description: string;
|
||||||
items: number
|
items: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingChoice {
|
export interface SettingChoice {
|
||||||
value: unknown
|
value: unknown;
|
||||||
label: string
|
label: string;
|
||||||
description?: string | null
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingHint {
|
export interface SettingHint {
|
||||||
description: string
|
description: string;
|
||||||
format: string
|
format: string;
|
||||||
example: string
|
example: string;
|
||||||
warning: string
|
warning: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingDefinition {
|
export interface SettingDefinition {
|
||||||
key: string
|
key: string;
|
||||||
name: string
|
name: string;
|
||||||
category: SettingCategoryRef
|
category: SettingCategoryRef;
|
||||||
type: string
|
type: string;
|
||||||
is_optional: boolean
|
is_optional: boolean;
|
||||||
current: unknown
|
current: unknown;
|
||||||
original: unknown
|
original: unknown;
|
||||||
has_override: boolean
|
has_override: boolean;
|
||||||
read_only: boolean
|
read_only: boolean;
|
||||||
choices: SettingChoice[]
|
choices: SettingChoice[];
|
||||||
hint?: SettingHint | null
|
hint?: SettingHint | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminSettingsApi = {
|
export const adminSettingsApi = {
|
||||||
// Get list of setting categories
|
// Get list of setting categories
|
||||||
getCategories: async (): Promise<SettingCategorySummary[]> => {
|
getCategories: async (): Promise<SettingCategorySummary[]> => {
|
||||||
const response = await apiClient.get<SettingCategorySummary[]>('/cabinet/admin/settings/categories')
|
const response = await apiClient.get<SettingCategorySummary[]>(
|
||||||
return response.data
|
'/cabinet/admin/settings/categories',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all settings or settings for a specific category
|
// Get all settings or settings for a specific category
|
||||||
getSettings: async (categoryKey?: string): Promise<SettingDefinition[]> => {
|
getSettings: async (categoryKey?: string): Promise<SettingDefinition[]> => {
|
||||||
const params = categoryKey ? { category_key: categoryKey } : {}
|
const params = categoryKey ? { category_key: categoryKey } : {};
|
||||||
const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', { params })
|
const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', {
|
||||||
return response.data
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get a specific setting by key
|
// Get a specific setting by key
|
||||||
getSetting: async (key: string): Promise<SettingDefinition> => {
|
getSetting: async (key: string): Promise<SettingDefinition> => {
|
||||||
const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`)
|
const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update a setting value
|
// Update a setting value
|
||||||
updateSetting: async (key: string, value: unknown): Promise<SettingDefinition> => {
|
updateSetting: async (key: string, value: unknown): Promise<SettingDefinition> => {
|
||||||
const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, { value })
|
const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, {
|
||||||
return response.data
|
value,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset a setting to default
|
// Reset a setting to default
|
||||||
resetSetting: async (key: string): Promise<SettingDefinition> => {
|
resetSetting: async (key: string): Promise<SettingDefinition> => {
|
||||||
const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`)
|
const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,418 +1,478 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
|
|
||||||
export interface UserSubscriptionInfo {
|
export interface UserSubscriptionInfo {
|
||||||
id: number
|
id: number;
|
||||||
status: string
|
status: string;
|
||||||
is_trial: boolean
|
is_trial: boolean;
|
||||||
start_date: string | null
|
start_date: string | null;
|
||||||
end_date: string | null
|
end_date: string | null;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
traffic_used_gb: number
|
traffic_used_gb: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
tariff_id: number | null
|
tariff_id: number | null;
|
||||||
tariff_name: string | null
|
tariff_name: string | null;
|
||||||
autopay_enabled: boolean
|
autopay_enabled: boolean;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
days_remaining: number
|
days_remaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserPromoGroupInfo {
|
export interface UserPromoGroupInfo {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
is_default: boolean
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserListItem {
|
export interface UserListItem {
|
||||||
id: number
|
id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
last_name: string | null
|
last_name: string | null;
|
||||||
full_name: string
|
full_name: string;
|
||||||
status: string
|
status: string;
|
||||||
balance_kopeks: number
|
balance_kopeks: number;
|
||||||
balance_rubles: number
|
balance_rubles: number;
|
||||||
created_at: string
|
created_at: string;
|
||||||
last_activity: string | null
|
last_activity: string | null;
|
||||||
has_subscription: boolean
|
has_subscription: boolean;
|
||||||
subscription_status: string | null
|
subscription_status: string | null;
|
||||||
subscription_is_trial: boolean
|
subscription_is_trial: boolean;
|
||||||
subscription_end_date: string | null
|
subscription_end_date: string | null;
|
||||||
promo_group_id: number | null
|
promo_group_id: number | null;
|
||||||
promo_group_name: string | null
|
promo_group_name: string | null;
|
||||||
total_spent_kopeks: number
|
total_spent_kopeks: number;
|
||||||
purchase_count: number
|
purchase_count: number;
|
||||||
has_restrictions: boolean
|
has_restrictions: boolean;
|
||||||
restriction_topup: boolean
|
restriction_topup: boolean;
|
||||||
restriction_subscription: boolean
|
restriction_subscription: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersListResponse {
|
export interface UsersListResponse {
|
||||||
users: UserListItem[]
|
users: UserListItem[];
|
||||||
total: number
|
total: number;
|
||||||
offset: number
|
offset: number;
|
||||||
limit: number
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserTransactionItem {
|
export interface UserTransactionItem {
|
||||||
id: number
|
id: number;
|
||||||
type: string
|
type: string;
|
||||||
amount_kopeks: number
|
amount_kopeks: number;
|
||||||
amount_rubles: number
|
amount_rubles: number;
|
||||||
description: string | null
|
description: string | null;
|
||||||
payment_method: string | null
|
payment_method: string | null;
|
||||||
is_completed: boolean
|
is_completed: boolean;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserReferralInfo {
|
export interface UserReferralInfo {
|
||||||
referral_code: string
|
referral_code: string;
|
||||||
referrals_count: number
|
referrals_count: number;
|
||||||
total_earnings_kopeks: number
|
total_earnings_kopeks: number;
|
||||||
commission_percent: number | null
|
commission_percent: number | null;
|
||||||
referred_by_id: number | null
|
referred_by_id: number | null;
|
||||||
referred_by_username: string | null
|
referred_by_username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDetailResponse {
|
export interface UserDetailResponse {
|
||||||
id: number
|
id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
last_name: string | null
|
last_name: string | null;
|
||||||
full_name: string
|
full_name: string;
|
||||||
status: string
|
status: string;
|
||||||
language: string
|
language: string;
|
||||||
balance_kopeks: number
|
balance_kopeks: number;
|
||||||
balance_rubles: number
|
balance_rubles: number;
|
||||||
email: string | null
|
email: string | null;
|
||||||
email_verified: boolean
|
email_verified: boolean;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
last_activity: string | null
|
last_activity: string | null;
|
||||||
cabinet_last_login: string | null
|
cabinet_last_login: string | null;
|
||||||
subscription: UserSubscriptionInfo | null
|
subscription: UserSubscriptionInfo | null;
|
||||||
promo_group: UserPromoGroupInfo | null
|
promo_group: UserPromoGroupInfo | null;
|
||||||
referral: UserReferralInfo
|
referral: UserReferralInfo;
|
||||||
total_spent_kopeks: number
|
total_spent_kopeks: number;
|
||||||
purchase_count: number
|
purchase_count: number;
|
||||||
used_promocodes: number
|
used_promocodes: number;
|
||||||
has_had_paid_subscription: boolean
|
has_had_paid_subscription: boolean;
|
||||||
lifetime_used_traffic_bytes: number
|
lifetime_used_traffic_bytes: number;
|
||||||
restriction_topup: boolean
|
restriction_topup: boolean;
|
||||||
restriction_subscription: boolean
|
restriction_subscription: boolean;
|
||||||
restriction_reason: string | null
|
restriction_reason: string | null;
|
||||||
promo_offer_discount_percent: number
|
promo_offer_discount_percent: number;
|
||||||
promo_offer_discount_source: string | null
|
promo_offer_discount_source: string | null;
|
||||||
promo_offer_discount_expires_at: string | null
|
promo_offer_discount_expires_at: string | null;
|
||||||
recent_transactions: UserTransactionItem[]
|
recent_transactions: UserTransactionItem[];
|
||||||
remnawave_uuid: string | null
|
remnawave_uuid: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersStatsResponse {
|
export interface UsersStatsResponse {
|
||||||
total_users: number
|
total_users: number;
|
||||||
active_users: number
|
active_users: number;
|
||||||
blocked_users: number
|
blocked_users: number;
|
||||||
deleted_users: number
|
deleted_users: number;
|
||||||
new_today: number
|
new_today: number;
|
||||||
new_week: number
|
new_week: number;
|
||||||
new_month: number
|
new_month: number;
|
||||||
users_with_subscription: number
|
users_with_subscription: number;
|
||||||
users_with_active_subscription: number
|
users_with_active_subscription: number;
|
||||||
users_with_trial: number
|
users_with_trial: number;
|
||||||
users_with_expired_subscription: number
|
users_with_expired_subscription: number;
|
||||||
total_balance_kopeks: number
|
total_balance_kopeks: number;
|
||||||
total_balance_rubles: number
|
total_balance_rubles: number;
|
||||||
avg_balance_kopeks: number
|
avg_balance_kopeks: number;
|
||||||
active_today: number
|
active_today: number;
|
||||||
active_week: number
|
active_week: number;
|
||||||
active_month: number
|
active_month: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available tariffs
|
// Available tariffs
|
||||||
export interface PeriodPriceInfo {
|
export interface PeriodPriceInfo {
|
||||||
days: number
|
days: number;
|
||||||
price_kopeks: number
|
price_kopeks: number;
|
||||||
price_rubles: number
|
price_rubles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAvailableTariff {
|
export interface UserAvailableTariff {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
is_trial_available: boolean
|
is_trial_available: boolean;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
tier_level: number
|
tier_level: number;
|
||||||
display_order: number
|
display_order: number;
|
||||||
period_prices: PeriodPriceInfo[]
|
period_prices: PeriodPriceInfo[];
|
||||||
is_daily: boolean
|
is_daily: boolean;
|
||||||
daily_price_kopeks: number
|
daily_price_kopeks: number;
|
||||||
custom_days_enabled: boolean
|
custom_days_enabled: boolean;
|
||||||
price_per_day_kopeks: number
|
price_per_day_kopeks: number;
|
||||||
min_days: number
|
min_days: number;
|
||||||
max_days: number
|
max_days: number;
|
||||||
is_available: boolean
|
is_available: boolean;
|
||||||
requires_promo_group: boolean
|
requires_promo_group: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAvailableTariffsResponse {
|
export interface UserAvailableTariffsResponse {
|
||||||
user_id: number
|
user_id: number;
|
||||||
promo_group_id: number | null
|
promo_group_id: number | null;
|
||||||
promo_group_name: string | null
|
promo_group_name: string | null;
|
||||||
tariffs: UserAvailableTariff[]
|
tariffs: UserAvailableTariff[];
|
||||||
total: number
|
total: number;
|
||||||
current_tariff_id: number | null
|
current_tariff_id: number | null;
|
||||||
current_tariff_name: string | null
|
current_tariff_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync types
|
// Sync types
|
||||||
export interface PanelUserInfo {
|
export interface PanelUserInfo {
|
||||||
uuid: string | null
|
uuid: string | null;
|
||||||
short_uuid: string | null
|
short_uuid: string | null;
|
||||||
username: string | null
|
username: string | null;
|
||||||
status: string | null
|
status: string | null;
|
||||||
expire_at: string | null
|
expire_at: string | null;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
traffic_used_gb: number
|
traffic_used_gb: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
subscription_url: string | null
|
subscription_url: string | null;
|
||||||
active_squads: string[]
|
active_squads: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncFromPanelResponse {
|
export interface SyncFromPanelResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
panel_user: PanelUserInfo | null
|
panel_user: PanelUserInfo | null;
|
||||||
changes: Record<string, unknown>
|
changes: Record<string, unknown>;
|
||||||
errors: string[]
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncToPanelResponse {
|
export interface SyncToPanelResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
action: string
|
action: string;
|
||||||
panel_uuid: string | null
|
panel_uuid: string | null;
|
||||||
changes: Record<string, unknown>
|
changes: Record<string, unknown>;
|
||||||
errors: string[]
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelSyncStatusResponse {
|
export interface PanelSyncStatusResponse {
|
||||||
user_id: number
|
user_id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
remnawave_uuid: string | null
|
remnawave_uuid: string | null;
|
||||||
last_sync: string | null
|
last_sync: string | null;
|
||||||
bot_subscription_status: string | null
|
bot_subscription_status: string | null;
|
||||||
bot_subscription_end_date: string | null
|
bot_subscription_end_date: string | null;
|
||||||
bot_traffic_limit_gb: number
|
bot_traffic_limit_gb: number;
|
||||||
bot_traffic_used_gb: number
|
bot_traffic_used_gb: number;
|
||||||
bot_device_limit: number
|
bot_device_limit: number;
|
||||||
bot_squads: string[]
|
bot_squads: string[];
|
||||||
panel_found: boolean
|
panel_found: boolean;
|
||||||
panel_status: string | null
|
panel_status: string | null;
|
||||||
panel_expire_at: string | null
|
panel_expire_at: string | null;
|
||||||
panel_traffic_limit_gb: number
|
panel_traffic_limit_gb: number;
|
||||||
panel_traffic_used_gb: number
|
panel_traffic_used_gb: number;
|
||||||
panel_device_limit: number
|
panel_device_limit: number;
|
||||||
panel_squads: string[]
|
panel_squads: string[];
|
||||||
has_differences: boolean
|
has_differences: boolean;
|
||||||
differences: string[]
|
differences: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update types
|
// Update types
|
||||||
export interface UpdateBalanceRequest {
|
export interface UpdateBalanceRequest {
|
||||||
amount_kopeks: number
|
amount_kopeks: number;
|
||||||
description?: string
|
description?: string;
|
||||||
create_transaction?: boolean
|
create_transaction?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateBalanceResponse {
|
export interface UpdateBalanceResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
old_balance_kopeks: number
|
old_balance_kopeks: number;
|
||||||
new_balance_kopeks: number
|
new_balance_kopeks: number;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSubscriptionRequest {
|
export interface UpdateSubscriptionRequest {
|
||||||
action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create'
|
action:
|
||||||
days?: number
|
| 'extend'
|
||||||
end_date?: string
|
| 'set_end_date'
|
||||||
tariff_id?: number
|
| 'change_tariff'
|
||||||
traffic_limit_gb?: number
|
| 'set_traffic'
|
||||||
traffic_used_gb?: number
|
| 'toggle_autopay'
|
||||||
autopay_enabled?: boolean
|
| 'cancel'
|
||||||
is_trial?: boolean
|
| 'activate'
|
||||||
device_limit?: number
|
| 'create';
|
||||||
|
days?: number;
|
||||||
|
end_date?: string;
|
||||||
|
tariff_id?: number;
|
||||||
|
traffic_limit_gb?: number;
|
||||||
|
traffic_used_gb?: number;
|
||||||
|
autopay_enabled?: boolean;
|
||||||
|
is_trial?: boolean;
|
||||||
|
device_limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSubscriptionResponse {
|
export interface UpdateSubscriptionResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
subscription: UserSubscriptionInfo | null
|
subscription: UserSubscriptionInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserStatusResponse {
|
export interface UpdateUserStatusResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
old_status: string
|
old_status: string;
|
||||||
new_status: string
|
new_status: string;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateRestrictionsRequest {
|
export interface UpdateRestrictionsRequest {
|
||||||
restriction_topup?: boolean
|
restriction_topup?: boolean;
|
||||||
restriction_subscription?: boolean
|
restriction_subscription?: boolean;
|
||||||
restriction_reason?: string
|
restriction_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateRestrictionsResponse {
|
export interface UpdateRestrictionsResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
restriction_topup: boolean
|
restriction_topup: boolean;
|
||||||
restriction_subscription: boolean
|
restriction_subscription: boolean;
|
||||||
restriction_reason: string | null
|
restriction_reason: string | null;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncFromPanelRequest {
|
export interface SyncFromPanelRequest {
|
||||||
update_subscription?: boolean
|
update_subscription?: boolean;
|
||||||
update_traffic?: boolean
|
update_traffic?: boolean;
|
||||||
create_if_missing?: boolean
|
create_if_missing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncToPanelRequest {
|
export interface SyncToPanelRequest {
|
||||||
create_if_missing?: boolean
|
create_if_missing?: boolean;
|
||||||
update_status?: boolean
|
update_status?: boolean;
|
||||||
update_traffic_limit?: boolean
|
update_traffic_limit?: boolean;
|
||||||
update_expire_date?: boolean
|
update_expire_date?: boolean;
|
||||||
update_squads?: boolean
|
update_squads?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ API ============
|
// ============ API ============
|
||||||
|
|
||||||
export const adminUsersApi = {
|
export const adminUsersApi = {
|
||||||
// List users
|
// List users
|
||||||
getUsers: async (params: {
|
getUsers: async (
|
||||||
offset?: number
|
params: {
|
||||||
limit?: number
|
offset?: number;
|
||||||
search?: string
|
limit?: number;
|
||||||
status?: 'active' | 'blocked' | 'deleted'
|
search?: string;
|
||||||
sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count'
|
status?: 'active' | 'blocked' | 'deleted';
|
||||||
} = {}): Promise<UsersListResponse> => {
|
sort_by?:
|
||||||
const response = await apiClient.get('/cabinet/admin/users', { params })
|
| 'created_at'
|
||||||
return response.data
|
| 'balance'
|
||||||
|
| 'traffic'
|
||||||
|
| 'last_activity'
|
||||||
|
| 'total_spent'
|
||||||
|
| 'purchase_count';
|
||||||
|
} = {},
|
||||||
|
): Promise<UsersListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/users', { params });
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get users stats
|
// Get users stats
|
||||||
getStats: async (): Promise<UsersStatsResponse> => {
|
getStats: async (): Promise<UsersStatsResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/users/stats')
|
const response = await apiClient.get('/cabinet/admin/users/stats');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user detail
|
// Get user detail
|
||||||
getUser: async (userId: number): Promise<UserDetailResponse> => {
|
getUser: async (userId: number): Promise<UserDetailResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}`)
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user by telegram ID
|
// Get user by telegram ID
|
||||||
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
|
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`)
|
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available tariffs for user
|
// Get available tariffs for user
|
||||||
getAvailableTariffs: async (userId: number, includeInactive = false): Promise<UserAvailableTariffsResponse> => {
|
getAvailableTariffs: async (
|
||||||
|
userId: number,
|
||||||
|
includeInactive = false,
|
||||||
|
): Promise<UserAvailableTariffsResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, {
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, {
|
||||||
params: { include_inactive: includeInactive }
|
params: { include_inactive: includeInactive },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update balance
|
// Update balance
|
||||||
updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise<UpdateBalanceResponse> => {
|
updateBalance: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data)
|
userId: number,
|
||||||
return response.data
|
data: UpdateBalanceRequest,
|
||||||
|
): Promise<UpdateBalanceResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update subscription
|
// Update subscription
|
||||||
updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise<UpdateSubscriptionResponse> => {
|
updateSubscription: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data)
|
userId: number,
|
||||||
return response.data
|
data: UpdateSubscriptionRequest,
|
||||||
|
): Promise<UpdateSubscriptionResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise<UpdateUserStatusResponse> => {
|
updateStatus: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason })
|
userId: number,
|
||||||
return response.data
|
status: 'active' | 'blocked' | 'deleted',
|
||||||
|
reason?: string,
|
||||||
|
): Promise<UpdateUserStatusResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, {
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Block user
|
// Block user
|
||||||
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
|
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } })
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, {
|
||||||
return response.data
|
params: { reason },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Unblock user
|
// Unblock user
|
||||||
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
|
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`)
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update restrictions
|
// Update restrictions
|
||||||
updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise<UpdateRestrictionsResponse> => {
|
updateRestrictions: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data)
|
userId: number,
|
||||||
return response.data
|
data: UpdateRestrictionsRequest,
|
||||||
|
): Promise<UpdateRestrictionsResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update promo group
|
// Update promo group
|
||||||
updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => {
|
updatePromoGroup: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId })
|
userId: number,
|
||||||
return response.data
|
promoGroupId: number | null,
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, {
|
||||||
|
promo_group_id: promoGroupId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => {
|
deleteUser: async (
|
||||||
|
userId: number,
|
||||||
|
softDelete = true,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, {
|
const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, {
|
||||||
data: { soft_delete: softDelete, reason }
|
data: { soft_delete: softDelete, reason },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get referrals
|
// Get referrals
|
||||||
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
||||||
params: { offset, limit }
|
params: { offset, limit },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get transactions
|
// Get transactions
|
||||||
getTransactions: async (userId: number, params: {
|
getTransactions: async (
|
||||||
offset?: number
|
userId: number,
|
||||||
limit?: number
|
params: {
|
||||||
transaction_type?: string
|
offset?: number;
|
||||||
} = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => {
|
limit?: number;
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params })
|
transaction_type?: string;
|
||||||
return response.data
|
} = {},
|
||||||
|
): Promise<{
|
||||||
|
transactions: UserTransactionItem[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params });
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sync status
|
// Sync status
|
||||||
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
|
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`)
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sync from panel
|
// Sync from panel
|
||||||
syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise<SyncFromPanelResponse> => {
|
syncFromPanel: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data)
|
userId: number,
|
||||||
return response.data
|
data: SyncFromPanelRequest = {},
|
||||||
|
): Promise<SyncFromPanelResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sync to panel
|
// Sync to panel
|
||||||
syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise<SyncToPanelResponse> => {
|
syncToPanel: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data)
|
userId: number,
|
||||||
return response.data
|
data: SyncToPanelRequest = {},
|
||||||
|
): Promise<SyncToPanelResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'
|
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
// Telegram WebApp authentication
|
// Telegram WebApp authentication
|
||||||
loginTelegram: async (initData: string): Promise<AuthResponse> => {
|
loginTelegram: async (initData: string): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||||
init_data: initData,
|
init_data: initData,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Telegram Login Widget authentication
|
// Telegram Login Widget authentication
|
||||||
loginTelegramWidget: async (data: {
|
loginTelegramWidget: async (data: {
|
||||||
id: number
|
id: number;
|
||||||
first_name: string
|
first_name: string;
|
||||||
last_name?: string
|
last_name?: string;
|
||||||
username?: string
|
username?: string;
|
||||||
photo_url?: string
|
photo_url?: string;
|
||||||
auth_date: number
|
auth_date: number;
|
||||||
hash: string
|
hash: string;
|
||||||
}): Promise<AuthResponse> => {
|
}): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data)
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Email login
|
// Email login
|
||||||
@@ -29,63 +29,69 @@ export const authApi = {
|
|||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Register email (link to existing Telegram account)
|
// Register email (link to existing Telegram account)
|
||||||
registerEmail: async (email: string, password: string): Promise<{ message: string; email: string }> => {
|
registerEmail: async (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ message: string; email: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/email/register', {
|
const response = await apiClient.post('/cabinet/auth/email/register', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Register standalone email account (no Telegram required)
|
// Register standalone email account (no Telegram required)
|
||||||
// Returns message - user must verify email before login
|
// Returns message - user must verify email before login
|
||||||
registerEmailStandalone: async (data: {
|
registerEmailStandalone: async (data: {
|
||||||
email: string
|
email: string;
|
||||||
password: string
|
password: string;
|
||||||
first_name?: string
|
first_name?: string;
|
||||||
language?: string
|
language?: string;
|
||||||
referral_code?: string
|
referral_code?: string;
|
||||||
}): Promise<RegisterResponse> => {
|
}): Promise<RegisterResponse> => {
|
||||||
const response = await apiClient.post<RegisterResponse>('/cabinet/auth/email/register/standalone', data)
|
const response = await apiClient.post<RegisterResponse>(
|
||||||
return response.data
|
'/cabinet/auth/email/register/standalone',
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verify email and get auth tokens
|
// Verify email and get auth tokens
|
||||||
verifyEmail: async (token: string): Promise<AuthResponse> => {
|
verifyEmail: async (token: string): Promise<AuthResponse> => {
|
||||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token })
|
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Resend verification email
|
// Resend verification email
|
||||||
resendVerification: async (): Promise<{ message: string }> => {
|
resendVerification: async (): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/email/resend')
|
const response = await apiClient.post('/cabinet/auth/email/resend');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Refresh token
|
// Refresh token
|
||||||
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
|
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
|
||||||
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
|
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
logout: async (refreshToken: string): Promise<void> => {
|
logout: async (refreshToken: string): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/auth/logout', {
|
await apiClient.post('/cabinet/auth/logout', {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Forgot password
|
// Forgot password
|
||||||
forgotPassword: async (email: string): Promise<{ message: string }> => {
|
forgotPassword: async (email: string): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.post('/cabinet/auth/password/forgot', { email })
|
const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset password
|
// Reset password
|
||||||
@@ -93,13 +99,13 @@ export const authApi = {
|
|||||||
const response = await apiClient.post('/cabinet/auth/password/reset', {
|
const response = await apiClient.post('/cabinet/auth/password/reset', {
|
||||||
token,
|
token,
|
||||||
password,
|
password,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get current user
|
// Get current user
|
||||||
getMe: async (): Promise<User> => {
|
getMe: async (): Promise<User> => {
|
||||||
const response = await apiClient.get<User>('/cabinet/auth/me')
|
const response = await apiClient.get<User>('/cabinet/auth/me');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,100 +1,124 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
import type {
|
||||||
|
Balance,
|
||||||
|
Transaction,
|
||||||
|
PaymentMethod,
|
||||||
|
PaginatedResponse,
|
||||||
|
PendingPayment,
|
||||||
|
ManualCheckResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
export const balanceApi = {
|
export const balanceApi = {
|
||||||
// Get current balance
|
// Get current balance
|
||||||
getBalance: async (): Promise<Balance> => {
|
getBalance: async (): Promise<Balance> => {
|
||||||
const response = await apiClient.get<Balance>('/cabinet/balance')
|
const response = await apiClient.get<Balance>('/cabinet/balance');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get transaction history
|
// Get transaction history
|
||||||
getTransactions: async (params?: {
|
getTransactions: async (params?: {
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
type?: string
|
type?: string;
|
||||||
}): Promise<PaginatedResponse<Transaction>> => {
|
}): Promise<PaginatedResponse<Transaction>> => {
|
||||||
const response = await apiClient.get<PaginatedResponse<Transaction>>('/cabinet/balance/transactions', {
|
const response = await apiClient.get<PaginatedResponse<Transaction>>(
|
||||||
|
'/cabinet/balance/transactions',
|
||||||
|
{
|
||||||
params,
|
params,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available payment methods
|
// Get available payment methods
|
||||||
getPaymentMethods: async (): Promise<PaymentMethod[]> => {
|
getPaymentMethods: async (): Promise<PaymentMethod[]> => {
|
||||||
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods')
|
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create top-up payment
|
// Create top-up payment
|
||||||
createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{
|
createTopUp: async (
|
||||||
payment_id: string
|
amountKopeks: number,
|
||||||
payment_url: string
|
paymentMethod: string,
|
||||||
amount_kopeks: number
|
paymentOption?: string,
|
||||||
amount_rubles: number
|
): Promise<{
|
||||||
status: string
|
payment_id: string;
|
||||||
expires_at: string | null
|
payment_url: string;
|
||||||
|
amount_kopeks: number;
|
||||||
|
amount_rubles: number;
|
||||||
|
status: string;
|
||||||
|
expires_at: string | null;
|
||||||
}> => {
|
}> => {
|
||||||
const payload: {
|
const payload: {
|
||||||
amount_kopeks: number
|
amount_kopeks: number;
|
||||||
payment_method: string
|
payment_method: string;
|
||||||
payment_option?: string
|
payment_option?: string;
|
||||||
} = {
|
} = {
|
||||||
amount_kopeks: amountKopeks,
|
amount_kopeks: amountKopeks,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
}
|
};
|
||||||
if (paymentOption) {
|
if (paymentOption) {
|
||||||
payload.payment_option = paymentOption
|
payload.payment_option = paymentOption;
|
||||||
}
|
}
|
||||||
const response = await apiClient.post('/cabinet/balance/topup', payload)
|
const response = await apiClient.post('/cabinet/balance/topup', payload);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Activate promo code
|
// Activate promo code
|
||||||
activatePromocode: async (code: string): Promise<{
|
activatePromocode: async (
|
||||||
success: boolean
|
code: string,
|
||||||
message: string
|
): Promise<{
|
||||||
balance_before: number
|
success: boolean;
|
||||||
balance_after: number
|
message: string;
|
||||||
bonus_description: string | null
|
balance_before: number;
|
||||||
|
balance_after: number;
|
||||||
|
bonus_description: string | null;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/promocode/activate', { code })
|
const response = await apiClient.post('/cabinet/promocode/activate', { code });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create Telegram Stars invoice for Mini App balance top-up
|
// Create Telegram Stars invoice for Mini App balance top-up
|
||||||
createStarsInvoice: async (amountKopeks: number): Promise<{
|
createStarsInvoice: async (
|
||||||
invoice_url: string
|
amountKopeks: number,
|
||||||
stars_amount?: number
|
): Promise<{
|
||||||
amount_kopeks?: number
|
invoice_url: string;
|
||||||
|
stars_amount?: number;
|
||||||
|
amount_kopeks?: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/balance/stars-invoice', {
|
const response = await apiClient.post('/cabinet/balance/stars-invoice', {
|
||||||
amount_kopeks: amountKopeks,
|
amount_kopeks: amountKopeks,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get pending payments for manual verification
|
// Get pending payments for manual verification
|
||||||
getPendingPayments: async (params?: {
|
getPendingPayments: async (params?: {
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/balance/pending-payments', {
|
const response = await apiClient.get<PaginatedResponse<PendingPayment>>(
|
||||||
|
'/cabinet/balance/pending-payments',
|
||||||
|
{
|
||||||
params,
|
params,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get specific pending payment details
|
// Get specific pending payment details
|
||||||
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||||
const response = await apiClient.get<PendingPayment>(`/cabinet/balance/pending-payments/${method}/${paymentId}`)
|
const response = await apiClient.get<PendingPayment>(
|
||||||
return response.data
|
`/cabinet/balance/pending-payments/${method}/${paymentId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Manually check payment status
|
// Manually check payment status
|
||||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`)
|
const response = await apiClient.post<ManualCheckResponse>(
|
||||||
return response.data
|
`/cabinet/balance/pending-payments/${method}/${paymentId}/check`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,270 +1,270 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// === Types ===
|
// === Types ===
|
||||||
|
|
||||||
export interface BanSystemStatus {
|
export interface BanSystemStatus {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
configured: boolean
|
configured: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanSystemStats {
|
export interface BanSystemStats {
|
||||||
total_users: number
|
total_users: number;
|
||||||
active_users: number
|
active_users: number;
|
||||||
users_over_limit: number
|
users_over_limit: number;
|
||||||
total_requests: number
|
total_requests: number;
|
||||||
total_punishments: number
|
total_punishments: number;
|
||||||
active_punishments: number
|
active_punishments: number;
|
||||||
nodes_online: number
|
nodes_online: number;
|
||||||
nodes_total: number
|
nodes_total: number;
|
||||||
agents_online: number
|
agents_online: number;
|
||||||
agents_total: number
|
agents_total: number;
|
||||||
panel_connected: boolean
|
panel_connected: boolean;
|
||||||
uptime_seconds: number | null
|
uptime_seconds: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanUserIPInfo {
|
export interface BanUserIPInfo {
|
||||||
ip: string
|
ip: string;
|
||||||
first_seen: string | null
|
first_seen: string | null;
|
||||||
last_seen: string | null
|
last_seen: string | null;
|
||||||
node: string | null
|
node: string | null;
|
||||||
request_count: number
|
request_count: number;
|
||||||
country_code: string | null
|
country_code: string | null;
|
||||||
country_name: string | null
|
country_name: string | null;
|
||||||
city: string | null
|
city: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanUserRequestLog {
|
export interface BanUserRequestLog {
|
||||||
timestamp: string
|
timestamp: string;
|
||||||
source_ip: string
|
source_ip: string;
|
||||||
destination: string | null
|
destination: string | null;
|
||||||
dest_port: number | null
|
dest_port: number | null;
|
||||||
protocol: string | null
|
protocol: string | null;
|
||||||
action: string | null
|
action: string | null;
|
||||||
node: string | null
|
node: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanUserListItem {
|
export interface BanUserListItem {
|
||||||
email: string
|
email: string;
|
||||||
unique_ip_count: number
|
unique_ip_count: number;
|
||||||
total_requests: number
|
total_requests: number;
|
||||||
limit: number | null
|
limit: number | null;
|
||||||
is_over_limit: boolean
|
is_over_limit: boolean;
|
||||||
blocked_count: number
|
blocked_count: number;
|
||||||
last_seen: string | null
|
last_seen: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanUsersListResponse {
|
export interface BanUsersListResponse {
|
||||||
users: BanUserListItem[]
|
users: BanUserListItem[];
|
||||||
total: number
|
total: number;
|
||||||
offset: number
|
offset: number;
|
||||||
limit: number
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanUserDetailResponse {
|
export interface BanUserDetailResponse {
|
||||||
email: string
|
email: string;
|
||||||
unique_ip_count: number
|
unique_ip_count: number;
|
||||||
total_requests: number
|
total_requests: number;
|
||||||
limit: number | null
|
limit: number | null;
|
||||||
is_over_limit: boolean
|
is_over_limit: boolean;
|
||||||
blocked_count: number
|
blocked_count: number;
|
||||||
ips: BanUserIPInfo[]
|
ips: BanUserIPInfo[];
|
||||||
recent_requests: BanUserRequestLog[]
|
recent_requests: BanUserRequestLog[];
|
||||||
network_type: string | null
|
network_type: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPunishmentItem {
|
export interface BanPunishmentItem {
|
||||||
id: number | null
|
id: number | null;
|
||||||
user_id: string
|
user_id: string;
|
||||||
uuid: string | null
|
uuid: string | null;
|
||||||
username: string
|
username: string;
|
||||||
reason: string | null
|
reason: string | null;
|
||||||
punished_at: string
|
punished_at: string;
|
||||||
enable_at: string | null
|
enable_at: string | null;
|
||||||
ip_count: number
|
ip_count: number;
|
||||||
limit: number
|
limit: number;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
enabled_at: string | null
|
enabled_at: string | null;
|
||||||
node_name: string | null
|
node_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPunishmentsListResponse {
|
export interface BanPunishmentsListResponse {
|
||||||
punishments: BanPunishmentItem[]
|
punishments: BanPunishmentItem[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanHistoryResponse {
|
export interface BanHistoryResponse {
|
||||||
items: BanPunishmentItem[]
|
items: BanPunishmentItem[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanUserRequest {
|
export interface BanUserRequest {
|
||||||
username: string
|
username: string;
|
||||||
minutes: number
|
minutes: number;
|
||||||
reason?: string
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnbanResponse {
|
export interface UnbanResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanNodeItem {
|
export interface BanNodeItem {
|
||||||
name: string
|
name: string;
|
||||||
address: string | null
|
address: string | null;
|
||||||
is_connected: boolean
|
is_connected: boolean;
|
||||||
last_seen: string | null
|
last_seen: string | null;
|
||||||
users_count: number
|
users_count: number;
|
||||||
agent_stats: Record<string, unknown> | null
|
agent_stats: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanNodesListResponse {
|
export interface BanNodesListResponse {
|
||||||
nodes: BanNodeItem[]
|
nodes: BanNodeItem[];
|
||||||
total: number
|
total: number;
|
||||||
online: number
|
online: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanAgentItem {
|
export interface BanAgentItem {
|
||||||
node_name: string
|
node_name: string;
|
||||||
sent_total: number
|
sent_total: number;
|
||||||
dropped_total: number
|
dropped_total: number;
|
||||||
batches_total: number
|
batches_total: number;
|
||||||
reconnects: number
|
reconnects: number;
|
||||||
failures: number
|
failures: number;
|
||||||
queue_size: number
|
queue_size: number;
|
||||||
queue_max: number
|
queue_max: number;
|
||||||
dedup_checked: number
|
dedup_checked: number;
|
||||||
dedup_skipped: number
|
dedup_skipped: number;
|
||||||
filter_checked: number
|
filter_checked: number;
|
||||||
filter_filtered: number
|
filter_filtered: number;
|
||||||
health: string
|
health: string;
|
||||||
is_online: boolean
|
is_online: boolean;
|
||||||
last_report: string | null
|
last_report: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanAgentsSummary {
|
export interface BanAgentsSummary {
|
||||||
total_agents: number
|
total_agents: number;
|
||||||
online_agents: number
|
online_agents: number;
|
||||||
total_sent: number
|
total_sent: number;
|
||||||
total_dropped: number
|
total_dropped: number;
|
||||||
avg_queue_size: number
|
avg_queue_size: number;
|
||||||
healthy_count: number
|
healthy_count: number;
|
||||||
warning_count: number
|
warning_count: number;
|
||||||
critical_count: number
|
critical_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanAgentsListResponse {
|
export interface BanAgentsListResponse {
|
||||||
agents: BanAgentItem[]
|
agents: BanAgentItem[];
|
||||||
summary: BanAgentsSummary | null
|
summary: BanAgentsSummary | null;
|
||||||
total: number
|
total: number;
|
||||||
online: number
|
online: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanTrafficViolationItem {
|
export interface BanTrafficViolationItem {
|
||||||
id: number | null
|
id: number | null;
|
||||||
username: string
|
username: string;
|
||||||
email: string | null
|
email: string | null;
|
||||||
violation_type: string
|
violation_type: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
bytes_used: number
|
bytes_used: number;
|
||||||
bytes_limit: number
|
bytes_limit: number;
|
||||||
detected_at: string
|
detected_at: string;
|
||||||
resolved: boolean
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanTrafficViolationsResponse {
|
export interface BanTrafficViolationsResponse {
|
||||||
violations: BanTrafficViolationItem[]
|
violations: BanTrafficViolationItem[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanTrafficTopItem {
|
export interface BanTrafficTopItem {
|
||||||
username: string
|
username: string;
|
||||||
bytes_total: number
|
bytes_total: number;
|
||||||
bytes_limit: number | null
|
bytes_limit: number | null;
|
||||||
over_limit: boolean
|
over_limit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanTrafficResponse {
|
export interface BanTrafficResponse {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
stats: Record<string, unknown> | null
|
stats: Record<string, unknown> | null;
|
||||||
top_users: BanTrafficTopItem[]
|
top_users: BanTrafficTopItem[];
|
||||||
recent_violations: BanTrafficViolationItem[]
|
recent_violations: BanTrafficViolationItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Settings Types ===
|
// === Settings Types ===
|
||||||
|
|
||||||
export interface BanSettingDefinition {
|
export interface BanSettingDefinition {
|
||||||
key: string
|
key: string;
|
||||||
value: unknown
|
value: unknown;
|
||||||
type: string
|
type: string;
|
||||||
min_value: number | null
|
min_value: number | null;
|
||||||
max_value: number | null
|
max_value: number | null;
|
||||||
editable: boolean
|
editable: boolean;
|
||||||
description: string | null
|
description: string | null;
|
||||||
category: string | null
|
category: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanSettingsResponse {
|
export interface BanSettingsResponse {
|
||||||
settings: BanSettingDefinition[]
|
settings: BanSettingDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanWhitelistRequest {
|
export interface BanWhitelistRequest {
|
||||||
username: string
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Report Types ===
|
// === Report Types ===
|
||||||
|
|
||||||
export interface BanReportTopViolator {
|
export interface BanReportTopViolator {
|
||||||
username: string
|
username: string;
|
||||||
count: number
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanReportResponse {
|
export interface BanReportResponse {
|
||||||
period_hours: number
|
period_hours: number;
|
||||||
current_users: number
|
current_users: number;
|
||||||
current_ips: number
|
current_ips: number;
|
||||||
punishment_stats: Record<string, unknown> | null
|
punishment_stats: Record<string, unknown> | null;
|
||||||
top_violators: BanReportTopViolator[]
|
top_violators: BanReportTopViolator[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Health Types ===
|
// === Health Types ===
|
||||||
|
|
||||||
export interface BanHealthComponent {
|
export interface BanHealthComponent {
|
||||||
name: string
|
name: string;
|
||||||
status: string
|
status: string;
|
||||||
message: string | null
|
message: string | null;
|
||||||
details: Record<string, unknown> | null
|
details: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanHealthResponse {
|
export interface BanHealthResponse {
|
||||||
status: string
|
status: string;
|
||||||
uptime: number | null
|
uptime: number | null;
|
||||||
components: BanHealthComponent[]
|
components: BanHealthComponent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanHealthDetailedResponse {
|
export interface BanHealthDetailedResponse {
|
||||||
status: string
|
status: string;
|
||||||
uptime: number | null
|
uptime: number | null;
|
||||||
components: Record<string, unknown>
|
components: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Agent History Types ===
|
// === Agent History Types ===
|
||||||
|
|
||||||
export interface BanAgentHistoryItem {
|
export interface BanAgentHistoryItem {
|
||||||
timestamp: string
|
timestamp: string;
|
||||||
sent_total: number
|
sent_total: number;
|
||||||
dropped_total: number
|
dropped_total: number;
|
||||||
queue_size: number
|
queue_size: number;
|
||||||
batches_total: number
|
batches_total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanAgentHistoryResponse {
|
export interface BanAgentHistoryResponse {
|
||||||
node: string
|
node: string;
|
||||||
hours: number
|
hours: number;
|
||||||
records: number
|
records: number;
|
||||||
delta: Record<string, unknown> | null
|
delta: Record<string, unknown> | null;
|
||||||
first: Record<string, unknown> | null
|
first: Record<string, unknown> | null;
|
||||||
last: Record<string, unknown> | null
|
last: Record<string, unknown> | null;
|
||||||
history: BanAgentHistoryItem[]
|
history: BanAgentHistoryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// === API ===
|
// === API ===
|
||||||
@@ -272,174 +272,198 @@ export interface BanAgentHistoryResponse {
|
|||||||
export const banSystemApi = {
|
export const banSystemApi = {
|
||||||
// Status
|
// Status
|
||||||
getStatus: async (): Promise<BanSystemStatus> => {
|
getStatus: async (): Promise<BanSystemStatus> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/status')
|
const response = await apiClient.get('/cabinet/admin/ban-system/status');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getStats: async (): Promise<BanSystemStats> => {
|
getStats: async (): Promise<BanSystemStats> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/stats')
|
const response = await apiClient.get('/cabinet/admin/ban-system/stats');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
getUsers: async (params: {
|
getUsers: async (
|
||||||
offset?: number
|
params: {
|
||||||
limit?: number
|
offset?: number;
|
||||||
status?: string
|
limit?: number;
|
||||||
} = {}): Promise<BanUsersListResponse> => {
|
status?: string;
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params })
|
} = {},
|
||||||
return response.data
|
): Promise<BanUsersListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params });
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUsersOverLimit: async (limit: number = 50): Promise<BanUsersListResponse> => {
|
getUsersOverLimit: async (limit: number = 50): Promise<BanUsersListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', {
|
const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', {
|
||||||
params: { limit }
|
params: { limit },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
searchUsers: async (query: string): Promise<BanUsersListResponse> => {
|
searchUsers: async (query: string): Promise<BanUsersListResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`)
|
const response = await apiClient.get(
|
||||||
return response.data
|
`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUser: async (email: string): Promise<BanUserDetailResponse> => {
|
getUser: async (email: string): Promise<BanUserDetailResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`)
|
const response = await apiClient.get(
|
||||||
return response.data
|
`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Punishments
|
// Punishments
|
||||||
getPunishments: async (): Promise<BanPunishmentsListResponse> => {
|
getPunishments: async (): Promise<BanPunishmentsListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/punishments')
|
const response = await apiClient.get('/cabinet/admin/ban-system/punishments');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
unbanUser: async (userId: string): Promise<UnbanResponse> => {
|
unbanUser: async (userId: string): Promise<UnbanResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`)
|
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
banUser: async (data: BanUserRequest): Promise<UnbanResponse> => {
|
banUser: async (data: BanUserRequest): Promise<UnbanResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data)
|
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getPunishmentHistory: async (query: string, limit: number = 20): Promise<BanHistoryResponse> => {
|
getPunishmentHistory: async (query: string, limit: number = 20): Promise<BanHistoryResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, {
|
const response = await apiClient.get(
|
||||||
params: { limit }
|
`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`,
|
||||||
})
|
{
|
||||||
return response.data
|
params: { limit },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Nodes
|
// Nodes
|
||||||
getNodes: async (): Promise<BanNodesListResponse> => {
|
getNodes: async (): Promise<BanNodesListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/nodes')
|
const response = await apiClient.get('/cabinet/admin/ban-system/nodes');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
getAgents: async (params: {
|
getAgents: async (
|
||||||
search?: string
|
params: {
|
||||||
health?: string
|
search?: string;
|
||||||
status?: string
|
health?: string;
|
||||||
} = {}): Promise<BanAgentsListResponse> => {
|
status?: string;
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params })
|
} = {},
|
||||||
return response.data
|
): Promise<BanAgentsListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params });
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getAgentsSummary: async (): Promise<BanAgentsSummary> => {
|
getAgentsSummary: async (): Promise<BanAgentsSummary> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary')
|
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Traffic violations
|
// Traffic violations
|
||||||
getTrafficViolations: async (limit: number = 50): Promise<BanTrafficViolationsResponse> => {
|
getTrafficViolations: async (limit: number = 50): Promise<BanTrafficViolationsResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', {
|
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', {
|
||||||
params: { limit }
|
params: { limit },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Full Traffic
|
// Full Traffic
|
||||||
getTraffic: async (): Promise<BanTrafficResponse> => {
|
getTraffic: async (): Promise<BanTrafficResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic')
|
const response = await apiClient.get('/cabinet/admin/ban-system/traffic');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTrafficTop: async (limit: number = 20): Promise<BanTrafficTopItem[]> => {
|
getTrafficTop: async (limit: number = 20): Promise<BanTrafficTopItem[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', {
|
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', {
|
||||||
params: { limit }
|
params: { limit },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
getSettings: async (): Promise<BanSettingsResponse> => {
|
getSettings: async (): Promise<BanSettingsResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/settings')
|
const response = await apiClient.get('/cabinet/admin/ban-system/settings');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getSetting: async (key: string): Promise<BanSettingDefinition> => {
|
getSetting: async (key: string): Promise<BanSettingDefinition> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`)
|
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSetting: async (key: string, value: string): Promise<BanSettingDefinition> => {
|
setSetting: async (key: string, value: string): Promise<BanSettingDefinition> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, {
|
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, {
|
||||||
params: { value }
|
params: { value },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSetting: async (key: string): Promise<BanSettingDefinition> => {
|
toggleSetting: async (key: string): Promise<BanSettingDefinition> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`)
|
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Whitelist
|
// Whitelist
|
||||||
whitelistAdd: async (username: string): Promise<UnbanResponse> => {
|
whitelistAdd: async (username: string): Promise<UnbanResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { username })
|
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', {
|
||||||
return response.data
|
username,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
whitelistRemove: async (username: string): Promise<UnbanResponse> => {
|
whitelistRemove: async (username: string): Promise<UnbanResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { username })
|
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', {
|
||||||
return response.data
|
username,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reports
|
// Reports
|
||||||
getReport: async (hours: number = 24): Promise<BanReportResponse> => {
|
getReport: async (hours: number = 24): Promise<BanReportResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/report', {
|
const response = await apiClient.get('/cabinet/admin/ban-system/report', {
|
||||||
params: { hours }
|
params: { hours },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
getHealth: async (): Promise<BanHealthResponse> => {
|
getHealth: async (): Promise<BanHealthResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/health')
|
const response = await apiClient.get('/cabinet/admin/ban-system/health');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getHealthDetailed: async (): Promise<BanHealthDetailedResponse> => {
|
getHealthDetailed: async (): Promise<BanHealthDetailedResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed')
|
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Agent History
|
// Agent History
|
||||||
getAgentHistory: async (nodeName: string, hours: number = 24): Promise<BanAgentHistoryResponse> => {
|
getAgentHistory: async (
|
||||||
const response = await apiClient.get(`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, {
|
nodeName: string,
|
||||||
params: { hours }
|
hours: number = 24,
|
||||||
})
|
): Promise<BanAgentHistoryResponse> => {
|
||||||
return response.data
|
const response = await apiClient.get(
|
||||||
|
`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`,
|
||||||
|
{
|
||||||
|
params: { hours },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// User Punishment History
|
// User Punishment History
|
||||||
getUserHistory: async (email: string, limit: number = 20): Promise<BanHistoryResponse> => {
|
getUserHistory: async (email: string, limit: number = 20): Promise<BanHistoryResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, {
|
const response = await apiClient.get(
|
||||||
params: { limit }
|
`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`,
|
||||||
})
|
{
|
||||||
return response.data
|
params: { limit },
|
||||||
},
|
},
|
||||||
}
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,203 +1,209 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface BrandingInfo {
|
export interface BrandingInfo {
|
||||||
name: string
|
name: string;
|
||||||
logo_url: string | null
|
logo_url: string | null;
|
||||||
logo_letter: string
|
logo_letter: string;
|
||||||
has_custom_logo: boolean
|
has_custom_logo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnimationEnabled {
|
export interface AnimationEnabled {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FullscreenEnabled {
|
export interface FullscreenEnabled {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmailAuthEnabled {
|
export interface EmailAuthEnabled {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyticsCounters {
|
export interface AnalyticsCounters {
|
||||||
yandex_metrika_id: string
|
yandex_metrika_id: string;
|
||||||
google_ads_id: string
|
google_ads_id: string;
|
||||||
google_ads_label: string
|
google_ads_label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BRANDING_CACHE_KEY = 'cabinet_branding'
|
const BRANDING_CACHE_KEY = 'cabinet_branding';
|
||||||
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
|
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded';
|
||||||
|
|
||||||
// Check if logo was already preloaded in this session
|
// Check if logo was already preloaded in this session
|
||||||
export const isLogoPreloaded = (): boolean => {
|
export const isLogoPreloaded = (): boolean => {
|
||||||
try {
|
try {
|
||||||
const cached = getCachedBranding()
|
const cached = getCachedBranding();
|
||||||
if (!cached?.has_custom_logo || !cached?.logo_url) {
|
if (!cached?.has_custom_logo || !cached?.logo_url) {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`
|
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`;
|
||||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY)
|
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY);
|
||||||
return preloaded === logoUrl
|
return preloaded === logoUrl;
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get cached branding from localStorage
|
// Get cached branding from localStorage
|
||||||
export const getCachedBranding = (): BrandingInfo | null => {
|
export const getCachedBranding = (): BrandingInfo | null => {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(BRANDING_CACHE_KEY)
|
const cached = localStorage.getItem(BRANDING_CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached)
|
return JSON.parse(cached);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage not available or invalid JSON
|
// localStorage not available or invalid JSON
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Update branding cache in localStorage
|
// Update branding cache in localStorage
|
||||||
export const setCachedBranding = (branding: BrandingInfo) => {
|
export const setCachedBranding = (branding: BrandingInfo) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding))
|
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding));
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage not available
|
// localStorage not available
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Preload logo image for instant display
|
// Preload logo image for instant display
|
||||||
export const preloadLogo = (branding: BrandingInfo): Promise<void> => {
|
export const preloadLogo = (branding: BrandingInfo): Promise<void> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!branding.has_custom_logo || !branding.logo_url) {
|
if (!branding.has_custom_logo || !branding.logo_url) {
|
||||||
resolve()
|
resolve();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
|
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`;
|
||||||
|
|
||||||
// Check if already preloaded in this session
|
// Check if already preloaded in this session
|
||||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY)
|
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY);
|
||||||
if (preloaded === logoUrl) {
|
if (preloaded === logoUrl) {
|
||||||
resolve()
|
resolve();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = new Image()
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl)
|
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl);
|
||||||
resolve()
|
resolve();
|
||||||
}
|
};
|
||||||
img.onerror = () => resolve()
|
img.onerror = () => resolve();
|
||||||
img.src = logoUrl
|
img.src = logoUrl;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// Initialize logo preload from cache on page load
|
// Initialize logo preload from cache on page load
|
||||||
export const initLogoPreload = () => {
|
export const initLogoPreload = () => {
|
||||||
const cached = getCachedBranding()
|
const cached = getCachedBranding();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
preloadLogo(cached)
|
preloadLogo(cached);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const brandingApi = {
|
export const brandingApi = {
|
||||||
// Get current branding (public, no auth required)
|
// Get current branding (public, no auth required)
|
||||||
getBranding: async (): Promise<BrandingInfo> => {
|
getBranding: async (): Promise<BrandingInfo> => {
|
||||||
const response = await apiClient.get<BrandingInfo>('/cabinet/branding')
|
const response = await apiClient.get<BrandingInfo>('/cabinet/branding');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update project name (admin only)
|
// Update project name (admin only)
|
||||||
updateName: async (name: string): Promise<BrandingInfo> => {
|
updateName: async (name: string): Promise<BrandingInfo> => {
|
||||||
const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name })
|
const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Upload custom logo (admin only)
|
// Upload custom logo (admin only)
|
||||||
uploadLogo: async (file: File): Promise<BrandingInfo> => {
|
uploadLogo: async (file: File): Promise<BrandingInfo> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('file', file)
|
formData.append('file', file);
|
||||||
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, {
|
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete custom logo (admin only)
|
// Delete custom logo (admin only)
|
||||||
deleteLogo: async (): Promise<BrandingInfo> => {
|
deleteLogo: async (): Promise<BrandingInfo> => {
|
||||||
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo')
|
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get logo URL (without cache busting - server handles caching via Cache-Control headers)
|
// Get logo URL (without cache busting - server handles caching via Cache-Control headers)
|
||||||
getLogoUrl: (branding: BrandingInfo): string | null => {
|
getLogoUrl: (branding: BrandingInfo): string | null => {
|
||||||
if (!branding.has_custom_logo || !branding.logo_url) {
|
if (!branding.has_custom_logo || !branding.logo_url) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
|
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get animation enabled (public, no auth required)
|
// Get animation enabled (public, no auth required)
|
||||||
getAnimationEnabled: async (): Promise<AnimationEnabled> => {
|
getAnimationEnabled: async (): Promise<AnimationEnabled> => {
|
||||||
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation')
|
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update animation enabled (admin only)
|
// Update animation enabled (admin only)
|
||||||
updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => {
|
updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => {
|
||||||
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled })
|
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', {
|
||||||
return response.data
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get fullscreen enabled (public, no auth required)
|
// Get fullscreen enabled (public, no auth required)
|
||||||
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
|
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen')
|
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen');
|
||||||
return response.data
|
return response.data;
|
||||||
} catch {
|
} catch {
|
||||||
// If endpoint doesn't exist, default to disabled
|
// If endpoint doesn't exist, default to disabled
|
||||||
return { enabled: false }
|
return { enabled: false };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update fullscreen enabled (admin only)
|
// Update fullscreen enabled (admin only)
|
||||||
updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => {
|
updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => {
|
||||||
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled })
|
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', {
|
||||||
return response.data
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get email auth enabled (public, no auth required)
|
// Get email auth enabled (public, no auth required)
|
||||||
getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => {
|
getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<EmailAuthEnabled>('/cabinet/branding/email-auth')
|
const response = await apiClient.get<EmailAuthEnabled>('/cabinet/branding/email-auth');
|
||||||
return response.data
|
return response.data;
|
||||||
} catch {
|
} catch {
|
||||||
// If endpoint doesn't exist, default to enabled
|
// If endpoint doesn't exist, default to enabled
|
||||||
return { enabled: true }
|
return { enabled: true };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update email auth enabled (admin only)
|
// Update email auth enabled (admin only)
|
||||||
updateEmailAuthEnabled: async (enabled: boolean): Promise<EmailAuthEnabled> => {
|
updateEmailAuthEnabled: async (enabled: boolean): Promise<EmailAuthEnabled> => {
|
||||||
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', { enabled })
|
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', {
|
||||||
return response.data
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get analytics counters (public, no auth required)
|
// Get analytics counters (public, no auth required)
|
||||||
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
|
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics')
|
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics');
|
||||||
return response.data
|
return response.data;
|
||||||
} catch {
|
} catch {
|
||||||
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' }
|
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update analytics counters (admin only)
|
// Update analytics counters (admin only)
|
||||||
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
|
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
|
||||||
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data)
|
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,230 +1,241 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff'
|
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff';
|
||||||
|
|
||||||
export interface TariffInfo {
|
export interface TariffInfo {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignListItem {
|
export interface CampaignListItem {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
start_parameter: string
|
start_parameter: string;
|
||||||
bonus_type: CampaignBonusType
|
bonus_type: CampaignBonusType;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
registrations_count: number
|
registrations_count: number;
|
||||||
total_revenue_kopeks: number
|
total_revenue_kopeks: number;
|
||||||
conversion_rate: number
|
conversion_rate: number;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignListResponse {
|
export interface CampaignListResponse {
|
||||||
campaigns: CampaignListItem[]
|
campaigns: CampaignListItem[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignDetail {
|
export interface CampaignDetail {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
start_parameter: string
|
start_parameter: string;
|
||||||
bonus_type: CampaignBonusType
|
bonus_type: CampaignBonusType;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
balance_bonus_kopeks: number
|
balance_bonus_kopeks: number;
|
||||||
balance_bonus_rubles: number
|
balance_bonus_rubles: number;
|
||||||
subscription_duration_days: number | null
|
subscription_duration_days: number | null;
|
||||||
subscription_traffic_gb: number | null
|
subscription_traffic_gb: number | null;
|
||||||
subscription_device_limit: number | null
|
subscription_device_limit: number | null;
|
||||||
subscription_squads: string[]
|
subscription_squads: string[];
|
||||||
tariff_id: number | null
|
tariff_id: number | null;
|
||||||
tariff_duration_days: number | null
|
tariff_duration_days: number | null;
|
||||||
tariff: TariffInfo | null
|
tariff: TariffInfo | null;
|
||||||
created_by: number | null
|
created_by: number | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
deep_link: string | null
|
deep_link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignCreateRequest {
|
export interface CampaignCreateRequest {
|
||||||
name: string
|
name: string;
|
||||||
start_parameter: string
|
start_parameter: string;
|
||||||
bonus_type: CampaignBonusType
|
bonus_type: CampaignBonusType;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
balance_bonus_kopeks?: number
|
balance_bonus_kopeks?: number;
|
||||||
subscription_duration_days?: number
|
subscription_duration_days?: number;
|
||||||
subscription_traffic_gb?: number
|
subscription_traffic_gb?: number;
|
||||||
subscription_device_limit?: number
|
subscription_device_limit?: number;
|
||||||
subscription_squads?: string[]
|
subscription_squads?: string[];
|
||||||
tariff_id?: number
|
tariff_id?: number;
|
||||||
tariff_duration_days?: number
|
tariff_duration_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignUpdateRequest {
|
export interface CampaignUpdateRequest {
|
||||||
name?: string
|
name?: string;
|
||||||
start_parameter?: string
|
start_parameter?: string;
|
||||||
bonus_type?: CampaignBonusType
|
bonus_type?: CampaignBonusType;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
balance_bonus_kopeks?: number
|
balance_bonus_kopeks?: number;
|
||||||
subscription_duration_days?: number
|
subscription_duration_days?: number;
|
||||||
subscription_traffic_gb?: number
|
subscription_traffic_gb?: number;
|
||||||
subscription_device_limit?: number
|
subscription_device_limit?: number;
|
||||||
subscription_squads?: string[]
|
subscription_squads?: string[];
|
||||||
tariff_id?: number
|
tariff_id?: number;
|
||||||
tariff_duration_days?: number
|
tariff_duration_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignToggleResponse {
|
export interface CampaignToggleResponse {
|
||||||
id: number
|
id: number;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignStatistics {
|
export interface CampaignStatistics {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
start_parameter: string
|
start_parameter: string;
|
||||||
bonus_type: CampaignBonusType
|
bonus_type: CampaignBonusType;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
registrations: number
|
registrations: number;
|
||||||
balance_issued_kopeks: number
|
balance_issued_kopeks: number;
|
||||||
balance_issued_rubles: number
|
balance_issued_rubles: number;
|
||||||
subscription_issued: number
|
subscription_issued: number;
|
||||||
last_registration: string | null
|
last_registration: string | null;
|
||||||
total_revenue_kopeks: number
|
total_revenue_kopeks: number;
|
||||||
total_revenue_rubles: number
|
total_revenue_rubles: number;
|
||||||
avg_revenue_per_user_kopeks: number
|
avg_revenue_per_user_kopeks: number;
|
||||||
avg_revenue_per_user_rubles: number
|
avg_revenue_per_user_rubles: number;
|
||||||
avg_first_payment_kopeks: number
|
avg_first_payment_kopeks: number;
|
||||||
avg_first_payment_rubles: number
|
avg_first_payment_rubles: number;
|
||||||
trial_users_count: number
|
trial_users_count: number;
|
||||||
active_trials_count: number
|
active_trials_count: number;
|
||||||
conversion_count: number
|
conversion_count: number;
|
||||||
paid_users_count: number
|
paid_users_count: number;
|
||||||
conversion_rate: number
|
conversion_rate: number;
|
||||||
trial_conversion_rate: number
|
trial_conversion_rate: number;
|
||||||
deep_link: string | null
|
deep_link: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignRegistrationItem {
|
export interface CampaignRegistrationItem {
|
||||||
id: number
|
id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
bonus_type: string
|
bonus_type: string;
|
||||||
balance_bonus_kopeks: number
|
balance_bonus_kopeks: number;
|
||||||
subscription_duration_days: number | null
|
subscription_duration_days: number | null;
|
||||||
tariff_id: number | null
|
tariff_id: number | null;
|
||||||
tariff_duration_days: number | null
|
tariff_duration_days: number | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
user_balance_kopeks: number
|
user_balance_kopeks: number;
|
||||||
has_subscription: boolean
|
has_subscription: boolean;
|
||||||
has_paid: boolean
|
has_paid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignRegistrationsResponse {
|
export interface CampaignRegistrationsResponse {
|
||||||
registrations: CampaignRegistrationItem[]
|
registrations: CampaignRegistrationItem[];
|
||||||
total: number
|
total: number;
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignsOverview {
|
export interface CampaignsOverview {
|
||||||
total: number
|
total: number;
|
||||||
active: number
|
active: number;
|
||||||
inactive: number
|
inactive: number;
|
||||||
total_registrations: number
|
total_registrations: number;
|
||||||
total_balance_issued_kopeks: number
|
total_balance_issued_kopeks: number;
|
||||||
total_balance_issued_rubles: number
|
total_balance_issued_rubles: number;
|
||||||
total_subscription_issued: number
|
total_subscription_issued: number;
|
||||||
total_tariff_issued: number
|
total_tariff_issued: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerSquadInfo {
|
export interface ServerSquadInfo {
|
||||||
id: number
|
id: number;
|
||||||
squad_uuid: string
|
squad_uuid: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
country_code: string | null
|
country_code: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffListItem {
|
export interface TariffListItem {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const campaignsApi = {
|
export const campaignsApi = {
|
||||||
// Get campaigns overview
|
// Get campaigns overview
|
||||||
getOverview: async (): Promise<CampaignsOverview> => {
|
getOverview: async (): Promise<CampaignsOverview> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/campaigns/overview')
|
const response = await apiClient.get('/cabinet/admin/campaigns/overview');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all campaigns
|
// Get all campaigns
|
||||||
getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise<CampaignListResponse> => {
|
getCampaigns: async (
|
||||||
|
includeInactive = true,
|
||||||
|
offset = 0,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<CampaignListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/campaigns', {
|
const response = await apiClient.get('/cabinet/admin/campaigns', {
|
||||||
params: { include_inactive: includeInactive, offset, limit }
|
params: { include_inactive: includeInactive, offset, limit },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single campaign
|
// Get single campaign
|
||||||
getCampaign: async (campaignId: number): Promise<CampaignDetail> => {
|
getCampaign: async (campaignId: number): Promise<CampaignDetail> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`)
|
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get campaign statistics
|
// Get campaign statistics
|
||||||
getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => {
|
getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`)
|
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get campaign registrations
|
// Get campaign registrations
|
||||||
getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise<CampaignRegistrationsResponse> => {
|
getCampaignRegistrations: async (
|
||||||
|
campaignId: number,
|
||||||
|
page = 1,
|
||||||
|
perPage = 50,
|
||||||
|
): Promise<CampaignRegistrationsResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, {
|
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, {
|
||||||
params: { page, per_page: perPage }
|
params: { page, per_page: perPage },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create campaign
|
// Create campaign
|
||||||
createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => {
|
createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/campaigns', data)
|
const response = await apiClient.post('/cabinet/admin/campaigns', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update campaign
|
// Update campaign
|
||||||
updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise<CampaignDetail> => {
|
updateCampaign: async (
|
||||||
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data)
|
campaignId: number,
|
||||||
return response.data
|
data: CampaignUpdateRequest,
|
||||||
|
): Promise<CampaignDetail> => {
|
||||||
|
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete campaign
|
// Delete campaign
|
||||||
deleteCampaign: async (campaignId: number): Promise<{ message: string }> => {
|
deleteCampaign: async (campaignId: number): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`)
|
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle campaign active status
|
// Toggle campaign active status
|
||||||
toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => {
|
toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`)
|
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available servers for subscription bonus
|
// Get available servers for subscription bonus
|
||||||
getAvailableServers: async (): Promise<ServerSquadInfo[]> => {
|
getAvailableServers: async (): Promise<ServerSquadInfo[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers')
|
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available tariffs for tariff bonus
|
// Get available tariffs for tariff bonus
|
||||||
getAvailableTariffs: async (): Promise<TariffListItem[]> => {
|
getAvailableTariffs: async (): Promise<TariffListItem[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs')
|
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,163 +1,175 @@
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
|
import {
|
||||||
import { useBlockingStore } from '../store/blocking'
|
tokenStorage,
|
||||||
|
isTokenExpired,
|
||||||
|
tokenRefreshManager,
|
||||||
|
safeRedirectToLogin,
|
||||||
|
} from '../utils/token';
|
||||||
|
import { useBlockingStore } from '../store/blocking';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
// Настраиваем endpoint для refresh
|
// Настраиваем endpoint для refresh
|
||||||
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
|
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
|
||||||
|
|
||||||
// CSRF token management
|
// CSRF token management
|
||||||
const CSRF_COOKIE_NAME = 'csrf_token'
|
const CSRF_COOKIE_NAME = 'csrf_token';
|
||||||
const CSRF_HEADER_NAME = 'X-CSRF-Token'
|
const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||||
|
|
||||||
function getCsrfToken(): string | null {
|
function getCsrfToken(): string | null {
|
||||||
if (typeof document === 'undefined') return null
|
if (typeof document === 'undefined') return null;
|
||||||
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`))
|
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`));
|
||||||
return match ? match[2] : null
|
return match ? match[2] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateCsrfToken(): string {
|
function generateCsrfToken(): string {
|
||||||
const array = new Uint8Array(32)
|
const array = new Uint8Array(32);
|
||||||
crypto.getRandomValues(array)
|
crypto.getRandomValues(array);
|
||||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCsrfToken(): string {
|
function ensureCsrfToken(): string {
|
||||||
let token = getCsrfToken()
|
let token = getCsrfToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
token = generateCsrfToken()
|
token = generateCsrfToken();
|
||||||
// Set cookie with SameSite=Strict for CSRF protection
|
// Set cookie with SameSite=Strict for CSRF protection
|
||||||
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`
|
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
|
||||||
}
|
}
|
||||||
return token
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTelegramInitData = (): string | null => {
|
const getTelegramInitData = (): string | null => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
const initData = window.Telegram?.WebApp?.initData
|
const initData = window.Telegram?.WebApp?.initData;
|
||||||
if (initData) {
|
if (initData) {
|
||||||
tokenStorage.setTelegramInitData(initData)
|
tokenStorage.setTelegramInitData(initData);
|
||||||
return initData
|
return initData;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenStorage.getTelegramInitData()
|
return tokenStorage.getTelegramInitData();
|
||||||
}
|
};
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Request interceptor - add auth token with expiration check
|
// Request interceptor - add auth token with expiration check
|
||||||
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||||
let token = tokenStorage.getAccessToken()
|
let token = tokenStorage.getAccessToken();
|
||||||
|
|
||||||
// Проверяем срок действия токена перед запросом
|
// Проверяем срок действия токена перед запросом
|
||||||
if (token && isTokenExpired(token)) {
|
if (token && isTokenExpired(token)) {
|
||||||
// Используем централизованный менеджер для refresh
|
// Используем централизованный менеджер для refresh
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
token = newToken
|
token = newToken;
|
||||||
} else {
|
} else {
|
||||||
// Refresh не удался - редирект на логин
|
// Refresh не удался - редирект на логин
|
||||||
tokenStorage.clearTokens()
|
tokenStorage.clearTokens();
|
||||||
safeRedirectToLogin()
|
safeRedirectToLogin();
|
||||||
return config
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const telegramInitData = getTelegramInitData()
|
const telegramInitData = getTelegramInitData();
|
||||||
if (telegramInitData && config.headers) {
|
if (telegramInitData && config.headers) {
|
||||||
config.headers['X-Telegram-Init-Data'] = telegramInitData
|
config.headers['X-Telegram-Init-Data'] = telegramInitData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add CSRF token for state-changing methods
|
// Add CSRF token for state-changing methods
|
||||||
const method = config.method?.toUpperCase()
|
const method = config.method?.toUpperCase();
|
||||||
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
||||||
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken()
|
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Custom error types for special handling
|
// Custom error types for special handling
|
||||||
export interface MaintenanceError {
|
export interface MaintenanceError {
|
||||||
code: 'maintenance'
|
code: 'maintenance';
|
||||||
message: string
|
message: string;
|
||||||
reason?: string
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelSubscriptionError {
|
export interface ChannelSubscriptionError {
|
||||||
code: 'channel_subscription_required'
|
code: 'channel_subscription_required';
|
||||||
message: string
|
message: string;
|
||||||
channel_link?: string
|
channel_link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } {
|
export function isMaintenanceError(
|
||||||
if (!error || typeof error !== 'object') return false
|
error: unknown,
|
||||||
const err = error as AxiosError<{ detail: MaintenanceError }>
|
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
|
||||||
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance'
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
const err = error as AxiosError<{ detail: MaintenanceError }>;
|
||||||
|
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } {
|
export function isChannelSubscriptionError(
|
||||||
if (!error || typeof error !== 'object') return false
|
error: unknown,
|
||||||
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>
|
): error is { response: { status: 403; data: { detail: ChannelSubscriptionError } } } {
|
||||||
return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required'
|
if (!error || typeof error !== 'object') return false;
|
||||||
|
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>;
|
||||||
|
return (
|
||||||
|
err.response?.status === 403 &&
|
||||||
|
err.response?.data?.detail?.code === 'channel_subscription_required'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
|
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
// Handle maintenance mode (503)
|
// Handle maintenance mode (503)
|
||||||
if (isMaintenanceError(error)) {
|
if (isMaintenanceError(error)) {
|
||||||
const detail = (error.response?.data as { detail: MaintenanceError }).detail
|
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
|
||||||
useBlockingStore.getState().setMaintenance({
|
useBlockingStore.getState().setMaintenance({
|
||||||
message: detail.message,
|
message: detail.message,
|
||||||
reason: detail.reason,
|
reason: detail.reason,
|
||||||
})
|
});
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle channel subscription required (403)
|
// Handle channel subscription required (403)
|
||||||
if (isChannelSubscriptionError(error)) {
|
if (isChannelSubscriptionError(error)) {
|
||||||
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail
|
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
|
||||||
useBlockingStore.getState().setChannelSubscription({
|
useBlockingStore.getState().setChannelSubscription({
|
||||||
message: detail.message,
|
message: detail.message,
|
||||||
channel_link: detail.channel_link,
|
channel_link: detail.channel_link,
|
||||||
})
|
});
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true;
|
||||||
|
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
if (originalRequest.headers) {
|
if (originalRequest.headers) {
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
}
|
}
|
||||||
return apiClient(originalRequest)
|
return apiClient(originalRequest);
|
||||||
} else {
|
} else {
|
||||||
// Refresh не удался
|
// Refresh не удался
|
||||||
tokenStorage.clearTokens()
|
tokenStorage.clearTokens();
|
||||||
safeRedirectToLogin()
|
safeRedirectToLogin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient;
|
||||||
|
|||||||
@@ -1,49 +1,50 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface ContestInfo {
|
export interface ContestInfo {
|
||||||
id: number
|
id: number;
|
||||||
slug: string
|
slug: string;
|
||||||
name: string
|
name: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
prize_days: number
|
prize_days: number;
|
||||||
is_available: boolean
|
is_available: boolean;
|
||||||
already_played: boolean
|
already_played: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContestGameData {
|
export interface ContestGameData {
|
||||||
round_id: number
|
round_id: number;
|
||||||
game_type: string
|
game_type: string;
|
||||||
game_data: Record<string, any>
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
instructions: string
|
game_data: Record<string, any>;
|
||||||
|
instructions: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContestResult {
|
export interface ContestResult {
|
||||||
is_winner: boolean
|
is_winner: boolean;
|
||||||
message: string
|
message: string;
|
||||||
prize_days?: number
|
prize_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContestsCountResponse {
|
export interface ContestsCountResponse {
|
||||||
count: number
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const contestsApi = {
|
export const contestsApi = {
|
||||||
// Get count of available contests
|
// Get count of available contests
|
||||||
getCount: async (): Promise<ContestsCountResponse> => {
|
getCount: async (): Promise<ContestsCountResponse> => {
|
||||||
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count')
|
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get list of available contests
|
// Get list of available contests
|
||||||
getContests: async (): Promise<ContestInfo[]> => {
|
getContests: async (): Promise<ContestInfo[]> => {
|
||||||
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests')
|
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get game data for a specific contest
|
// Get game data for a specific contest
|
||||||
getContestGame: async (roundId: number): Promise<ContestGameData> => {
|
getContestGame: async (roundId: number): Promise<ContestGameData> => {
|
||||||
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`)
|
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Submit answer for a contest
|
// Submit answer for a contest
|
||||||
@@ -51,7 +52,7 @@ export const contestsApi = {
|
|||||||
const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, {
|
const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, {
|
||||||
round_id: roundId,
|
round_id: roundId,
|
||||||
answer,
|
answer,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
// Currency exchange rate API
|
// Currency exchange rate API
|
||||||
// Uses free exchangerate.host API
|
// Uses free exchangerate.host API
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios';
|
||||||
|
|
||||||
interface ExchangeRates {
|
interface ExchangeRates {
|
||||||
USD: number
|
USD: number;
|
||||||
CNY: number
|
CNY: number;
|
||||||
IRR: number
|
IRR: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedRates {
|
interface CachedRates {
|
||||||
rates: ExchangeRates
|
rates: ExchangeRates;
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_DURATION = 60 * 60 * 1000 // 1 hour in milliseconds
|
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||||
|
|
||||||
// Fallback rates if API fails (approximate)
|
// Fallback rates if API fails (approximate)
|
||||||
const FALLBACK_RATES: ExchangeRates = {
|
const FALLBACK_RATES: ExchangeRates = {
|
||||||
USD: 100, // 1 USD = 100 RUB
|
USD: 100, // 1 USD = 100 RUB
|
||||||
CNY: 14, // 1 CNY = 14 RUB
|
CNY: 14, // 1 CNY = 14 RUB
|
||||||
IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR)
|
IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR)
|
||||||
}
|
};
|
||||||
|
|
||||||
let cachedRates: CachedRates | null = null
|
let cachedRates: CachedRates | null = null;
|
||||||
|
|
||||||
export const currencyApi = {
|
export const currencyApi = {
|
||||||
// Get all exchange rates (RUB to other currencies)
|
// Get all exchange rates (RUB to other currencies)
|
||||||
getExchangeRates: async (): Promise<ExchangeRates> => {
|
getExchangeRates: async (): Promise<ExchangeRates> => {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) {
|
if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) {
|
||||||
return cachedRates.rates
|
return cachedRates.rates;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try primary API (exchangerate.host) - get RUB based rates
|
// Try primary API (exchangerate.host) - get RUB based rates
|
||||||
const response = await axios.get<{
|
const response = await axios.get<{
|
||||||
success?: boolean
|
success?: boolean;
|
||||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
rates?: { USD?: number; CNY?: number; IRR?: number };
|
||||||
}>('https://api.exchangerate.host/latest', {
|
}>('https://api.exchangerate.host/latest', {
|
||||||
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
|
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (response.data.success && response.data.rates) {
|
if (response.data.success && response.data.rates) {
|
||||||
// API returns how much of each currency equals 1 RUB
|
// API returns how much of each currency equals 1 RUB
|
||||||
@@ -49,51 +49,65 @@ export const currencyApi = {
|
|||||||
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
|
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
|
||||||
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
|
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||||
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
|
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||||
}
|
};
|
||||||
cachedRates = { rates, timestamp: Date.now() }
|
cachedRates = { rates, timestamp: Date.now() };
|
||||||
return rates
|
return rates;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try backup API (open.er-api.com)
|
// Try backup API (open.er-api.com)
|
||||||
const backupResponse = await axios.get<{
|
const backupResponse = await axios.get<{
|
||||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
rates?: { USD?: number; CNY?: number; IRR?: number };
|
||||||
}>('https://open.er-api.com/v6/latest/RUB')
|
}>('https://open.er-api.com/v6/latest/RUB');
|
||||||
|
|
||||||
if (backupResponse.data.rates) {
|
if (backupResponse.data.rates) {
|
||||||
const rates: ExchangeRates = {
|
const rates: ExchangeRates = {
|
||||||
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
|
USD: backupResponse.data.rates.USD
|
||||||
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
|
? 1 / backupResponse.data.rates.USD
|
||||||
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
|
: FALLBACK_RATES.USD,
|
||||||
}
|
CNY: backupResponse.data.rates.CNY
|
||||||
cachedRates = { rates, timestamp: Date.now() }
|
? 1 / backupResponse.data.rates.CNY
|
||||||
return rates
|
: FALLBACK_RATES.CNY,
|
||||||
|
IRR: backupResponse.data.rates.IRR
|
||||||
|
? 1 / backupResponse.data.rates.IRR
|
||||||
|
: FALLBACK_RATES.IRR,
|
||||||
|
};
|
||||||
|
cachedRates = { rates, timestamp: Date.now() };
|
||||||
|
return rates;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return fallback rates if both APIs fail
|
// Return fallback rates if both APIs fail
|
||||||
return FALLBACK_RATES
|
return FALLBACK_RATES;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to fetch exchange rates, using fallback:', error)
|
console.warn('Failed to fetch exchange rates, using fallback:', error);
|
||||||
return FALLBACK_RATES
|
return FALLBACK_RATES;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Convert RUB to target currency
|
// Convert RUB to target currency
|
||||||
convertFromRub: (rubAmount: number, targetCurrency: keyof ExchangeRates, rates: ExchangeRates): number => {
|
convertFromRub: (
|
||||||
const rate = rates[targetCurrency]
|
rubAmount: number,
|
||||||
|
targetCurrency: keyof ExchangeRates,
|
||||||
|
rates: ExchangeRates,
|
||||||
|
): number => {
|
||||||
|
const rate = rates[targetCurrency];
|
||||||
if (!rate || rate <= 0) {
|
if (!rate || rate <= 0) {
|
||||||
return rubAmount / FALLBACK_RATES[targetCurrency]
|
return rubAmount / FALLBACK_RATES[targetCurrency];
|
||||||
}
|
}
|
||||||
return rubAmount / rate
|
return rubAmount / rate;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Convert from target currency to RUB
|
// Convert from target currency to RUB
|
||||||
convertToRub: (amount: number, sourceCurrency: keyof ExchangeRates, rates: ExchangeRates): number => {
|
convertToRub: (
|
||||||
const rate = rates[sourceCurrency]
|
amount: number,
|
||||||
|
sourceCurrency: keyof ExchangeRates,
|
||||||
|
rates: ExchangeRates,
|
||||||
|
): number => {
|
||||||
|
const rate = rates[sourceCurrency];
|
||||||
if (!rate || rate <= 0) {
|
if (!rate || rate <= 0) {
|
||||||
return amount * FALLBACK_RATES[sourceCurrency]
|
return amount * FALLBACK_RATES[sourceCurrency];
|
||||||
}
|
}
|
||||||
return amount * rate
|
return amount * rate;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export type { ExchangeRates }
|
export type { ExchangeRates };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
export { apiClient } from './client'
|
export { apiClient } from './client';
|
||||||
export { authApi } from './auth'
|
export { authApi } from './auth';
|
||||||
export { subscriptionApi } from './subscription'
|
export { subscriptionApi } from './subscription';
|
||||||
export { balanceApi } from './balance'
|
export { balanceApi } from './balance';
|
||||||
export { referralApi } from './referral'
|
export { referralApi } from './referral';
|
||||||
export { ticketsApi } from './tickets'
|
export { ticketsApi } from './tickets';
|
||||||
export { contestsApi } from './contests'
|
export { contestsApi } from './contests';
|
||||||
export { pollsApi } from './polls'
|
export { pollsApi } from './polls';
|
||||||
export { promoApi } from './promo'
|
export { promoApi } from './promo';
|
||||||
export { notificationsApi } from './notifications'
|
export { notificationsApi } from './notifications';
|
||||||
export { infoApi } from './info'
|
export { infoApi } from './info';
|
||||||
export { adminSettingsApi } from './adminSettings'
|
export { adminSettingsApi } from './adminSettings';
|
||||||
|
|||||||
@@ -1,102 +1,102 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { SupportConfig } from '../types'
|
import type { SupportConfig } from '../types';
|
||||||
|
|
||||||
export interface FaqPage {
|
export interface FaqPage {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
content: string
|
content: string;
|
||||||
order: number
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RulesResponse {
|
export interface RulesResponse {
|
||||||
content: string
|
content: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivacyPolicyResponse {
|
export interface PrivacyPolicyResponse {
|
||||||
content: string
|
content: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicOfferResponse {
|
export interface PublicOfferResponse {
|
||||||
content: string
|
content: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceInfo {
|
export interface ServiceInfo {
|
||||||
name: string
|
name: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
support_email: string | null
|
support_email: string | null;
|
||||||
support_telegram: string | null
|
support_telegram: string | null;
|
||||||
website: string | null
|
website: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LanguageInfo {
|
export interface LanguageInfo {
|
||||||
code: string
|
code: string;
|
||||||
name: string
|
name: string;
|
||||||
flag: string
|
flag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const infoApi = {
|
export const infoApi = {
|
||||||
// Get FAQ pages list
|
// Get FAQ pages list
|
||||||
getFaqPages: async (): Promise<FaqPage[]> => {
|
getFaqPages: async (): Promise<FaqPage[]> => {
|
||||||
const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq')
|
const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get specific FAQ page
|
// Get specific FAQ page
|
||||||
getFaqPage: async (pageId: number): Promise<FaqPage> => {
|
getFaqPage: async (pageId: number): Promise<FaqPage> => {
|
||||||
const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`)
|
const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get service rules
|
// Get service rules
|
||||||
getRules: async (): Promise<RulesResponse> => {
|
getRules: async (): Promise<RulesResponse> => {
|
||||||
const response = await apiClient.get<RulesResponse>('/cabinet/info/rules')
|
const response = await apiClient.get<RulesResponse>('/cabinet/info/rules');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get privacy policy
|
// Get privacy policy
|
||||||
getPrivacyPolicy: async (): Promise<PrivacyPolicyResponse> => {
|
getPrivacyPolicy: async (): Promise<PrivacyPolicyResponse> => {
|
||||||
const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy')
|
const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get public offer
|
// Get public offer
|
||||||
getPublicOffer: async (): Promise<PublicOfferResponse> => {
|
getPublicOffer: async (): Promise<PublicOfferResponse> => {
|
||||||
const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer')
|
const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get service info
|
// Get service info
|
||||||
getServiceInfo: async (): Promise<ServiceInfo> => {
|
getServiceInfo: async (): Promise<ServiceInfo> => {
|
||||||
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service')
|
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available languages
|
// Get available languages
|
||||||
getLanguages: async (): Promise<{ languages: LanguageInfo[]; default: string }> => {
|
getLanguages: async (): Promise<{ languages: LanguageInfo[]; default: string }> => {
|
||||||
const response = await apiClient.get('/cabinet/info/languages')
|
const response = await apiClient.get('/cabinet/info/languages');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user language
|
// Get user language
|
||||||
getUserLanguage: async (): Promise<{ language: string }> => {
|
getUserLanguage: async (): Promise<{ language: string }> => {
|
||||||
const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language')
|
const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update user language
|
// Update user language
|
||||||
updateUserLanguage: async (language: string): Promise<{ language: string }> => {
|
updateUserLanguage: async (language: string): Promise<{ language: string }> => {
|
||||||
const response = await apiClient.patch<{ language: string }>('/cabinet/info/user/language', {
|
const response = await apiClient.patch<{ language: string }>('/cabinet/info/user/language', {
|
||||||
language,
|
language,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get support configuration
|
// Get support configuration
|
||||||
getSupportConfig: async (): Promise<SupportConfig> => {
|
getSupportConfig: async (): Promise<SupportConfig> => {
|
||||||
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config')
|
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import axios, { AxiosError } from 'axios'
|
import axios, { AxiosError } from 'axios';
|
||||||
|
|
||||||
export interface MiniappCreatePaymentPayload {
|
export interface MiniappCreatePaymentPayload {
|
||||||
initData: string
|
initData: string;
|
||||||
method: string
|
method: string;
|
||||||
amountKopeks?: number | null
|
amountKopeks?: number | null;
|
||||||
option?: string | null
|
option?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MiniappCreatePaymentResponse {
|
export interface MiniappCreatePaymentResponse {
|
||||||
payment_url: string
|
payment_url: string;
|
||||||
amount_kopeks?: number
|
amount_kopeks?: number;
|
||||||
extra?: Record<string, unknown>
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const miniappApi = {
|
export const miniappApi = {
|
||||||
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
|
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
|
||||||
createPayment: async (
|
createPayment: async (
|
||||||
payload: MiniappCreatePaymentPayload
|
payload: MiniappCreatePaymentPayload,
|
||||||
): Promise<MiniappCreatePaymentResponse> => {
|
): Promise<MiniappCreatePaymentResponse> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<MiniappCreatePaymentResponse>(
|
const response = await axios.post<MiniappCreatePaymentResponse>(
|
||||||
@@ -29,15 +29,16 @@ export const miniappApi = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ detail?: string; message?: string }>
|
const axiosError = error as AxiosError<{ detail?: string; message?: string }>;
|
||||||
const message = axiosError.response?.data?.detail
|
const message =
|
||||||
|| axiosError.response?.data?.message
|
axiosError.response?.data?.detail ||
|
||||||
|| 'Failed to create payment'
|
axiosError.response?.data?.message ||
|
||||||
throw new Error(message)
|
'Failed to create payment';
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,58 +1,64 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
subscription_expiry_enabled: boolean
|
subscription_expiry_enabled: boolean;
|
||||||
subscription_expiry_days: number
|
subscription_expiry_days: number;
|
||||||
traffic_warning_enabled: boolean
|
traffic_warning_enabled: boolean;
|
||||||
traffic_warning_percent: number
|
traffic_warning_percent: number;
|
||||||
balance_low_enabled: boolean
|
balance_low_enabled: boolean;
|
||||||
balance_low_threshold: number
|
balance_low_threshold: number;
|
||||||
news_enabled: boolean
|
news_enabled: boolean;
|
||||||
promo_offers_enabled: boolean
|
promo_offers_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettingsUpdate {
|
export interface NotificationSettingsUpdate {
|
||||||
subscription_expiry_enabled?: boolean
|
subscription_expiry_enabled?: boolean;
|
||||||
subscription_expiry_days?: number
|
subscription_expiry_days?: number;
|
||||||
traffic_warning_enabled?: boolean
|
traffic_warning_enabled?: boolean;
|
||||||
traffic_warning_percent?: number
|
traffic_warning_percent?: number;
|
||||||
balance_low_enabled?: boolean
|
balance_low_enabled?: boolean;
|
||||||
balance_low_threshold?: number
|
balance_low_threshold?: number;
|
||||||
news_enabled?: boolean
|
news_enabled?: boolean;
|
||||||
promo_offers_enabled?: boolean
|
promo_offers_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
// Get notification settings
|
// Get notification settings
|
||||||
getSettings: async (): Promise<NotificationSettings> => {
|
getSettings: async (): Promise<NotificationSettings> => {
|
||||||
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications')
|
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update notification settings
|
// Update notification settings
|
||||||
updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => {
|
updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => {
|
||||||
const response = await apiClient.patch<NotificationSettings>('/cabinet/notifications', settings)
|
const response = await apiClient.patch<NotificationSettings>(
|
||||||
return response.data
|
'/cabinet/notifications',
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Send test notification
|
// Send test notification
|
||||||
sendTestNotification: async (): Promise<{ success: boolean; message: string }> => {
|
sendTestNotification: async (): Promise<{ success: boolean; message: string }> => {
|
||||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||||
'/cabinet/notifications/test'
|
'/cabinet/notifications/test',
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get notification history
|
// Get notification history
|
||||||
getHistory: async (limit = 20, offset = 0): Promise<{
|
getHistory: async (
|
||||||
notifications: any[]
|
limit = 20,
|
||||||
total: number
|
offset = 0,
|
||||||
limit: number
|
): Promise<{
|
||||||
offset: number
|
notifications: unknown[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/notifications/history', {
|
const response = await apiClient.get('/cabinet/notifications/history', {
|
||||||
params: { limit, offset },
|
params: { limit, offset },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,85 +1,85 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface PollOption {
|
export interface PollOption {
|
||||||
id: number
|
id: number;
|
||||||
text: string
|
text: string;
|
||||||
order: number
|
order: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollQuestion {
|
export interface PollQuestion {
|
||||||
id: number
|
id: number;
|
||||||
text: string
|
text: string;
|
||||||
order: number
|
order: number;
|
||||||
options: PollOption[]
|
options: PollOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollInfo {
|
export interface PollInfo {
|
||||||
id: number
|
id: number;
|
||||||
response_id: number
|
response_id: number;
|
||||||
title: string
|
title: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
total_questions: number
|
total_questions: number;
|
||||||
answered_questions: number
|
answered_questions: number;
|
||||||
is_completed: boolean
|
is_completed: boolean;
|
||||||
reward_amount: number | null
|
reward_amount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollStartResponse {
|
export interface PollStartResponse {
|
||||||
response_id: number
|
response_id: number;
|
||||||
current_question_index: number
|
current_question_index: number;
|
||||||
total_questions: number
|
total_questions: number;
|
||||||
question: PollQuestion
|
question: PollQuestion;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollAnswerResponse {
|
export interface PollAnswerResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
is_completed: boolean
|
is_completed: boolean;
|
||||||
next_question: PollQuestion | null
|
next_question: PollQuestion | null;
|
||||||
current_question_index: number | null
|
current_question_index: number | null;
|
||||||
total_questions: number
|
total_questions: number;
|
||||||
reward_granted: number | null
|
reward_granted: number | null;
|
||||||
message: string | null
|
message: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PollsCountResponse {
|
export interface PollsCountResponse {
|
||||||
count: number
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pollsApi = {
|
export const pollsApi = {
|
||||||
// Get count of available polls
|
// Get count of available polls
|
||||||
getCount: async (): Promise<PollsCountResponse> => {
|
getCount: async (): Promise<PollsCountResponse> => {
|
||||||
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count')
|
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available polls
|
// Get available polls
|
||||||
getPolls: async (): Promise<PollInfo[]> => {
|
getPolls: async (): Promise<PollInfo[]> => {
|
||||||
const response = await apiClient.get<PollInfo[]>('/cabinet/polls')
|
const response = await apiClient.get<PollInfo[]>('/cabinet/polls');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get poll details
|
// Get poll details
|
||||||
getPollDetails: async (responseId: number): Promise<PollInfo> => {
|
getPollDetails: async (responseId: number): Promise<PollInfo> => {
|
||||||
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`)
|
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Start or continue poll
|
// Start or continue poll
|
||||||
startPoll: async (responseId: number): Promise<PollStartResponse> => {
|
startPoll: async (responseId: number): Promise<PollStartResponse> => {
|
||||||
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`)
|
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Answer a question
|
// Answer a question
|
||||||
answerQuestion: async (
|
answerQuestion: async (
|
||||||
responseId: number,
|
responseId: number,
|
||||||
questionId: number,
|
questionId: number,
|
||||||
optionId: number
|
optionId: number,
|
||||||
): Promise<PollAnswerResponse> => {
|
): Promise<PollAnswerResponse> => {
|
||||||
const response = await apiClient.post<PollAnswerResponse>(
|
const response = await apiClient.post<PollAnswerResponse>(
|
||||||
`/cabinet/polls/${responseId}/questions/${questionId}/answer`,
|
`/cabinet/polls/${responseId}/questions/${questionId}/answer`,
|
||||||
{ option_id: optionId }
|
{ option_id: optionId },
|
||||||
)
|
);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
103
src/api/promo.ts
103
src/api/promo.ts
@@ -1,96 +1,97 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface PromoOffer {
|
export interface PromoOffer {
|
||||||
id: number
|
id: number;
|
||||||
notification_type: string
|
notification_type: string;
|
||||||
discount_percent: number | null
|
discount_percent: number | null;
|
||||||
effect_type: string
|
effect_type: string;
|
||||||
expires_at: string
|
expires_at: string;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
is_claimed: boolean
|
is_claimed: boolean;
|
||||||
claimed_at: string | null
|
claimed_at: string | null;
|
||||||
extra_data: Record<string, any> | null
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
extra_data: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveDiscount {
|
export interface ActiveDiscount {
|
||||||
discount_percent: number
|
discount_percent: number;
|
||||||
source: string | null
|
source: string | null;
|
||||||
expires_at: string | null
|
expires_at: string | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoGroupDiscounts {
|
export interface PromoGroupDiscounts {
|
||||||
group_name: string | null
|
group_name: string | null;
|
||||||
server_discount_percent: number
|
server_discount_percent: number;
|
||||||
traffic_discount_percent: number
|
traffic_discount_percent: number;
|
||||||
device_discount_percent: number
|
device_discount_percent: number;
|
||||||
period_discounts: Record<string, number>
|
period_discounts: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimOfferResponse {
|
export interface ClaimOfferResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
discount_percent: number | null
|
discount_percent: number | null;
|
||||||
expires_at: string | null
|
expires_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoyaltyTierInfo {
|
export interface LoyaltyTierInfo {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
threshold_rubles: number
|
threshold_rubles: number;
|
||||||
server_discount_percent: number
|
server_discount_percent: number;
|
||||||
traffic_discount_percent: number
|
traffic_discount_percent: number;
|
||||||
device_discount_percent: number
|
device_discount_percent: number;
|
||||||
period_discounts: Record<string, number>
|
period_discounts: Record<string, number>;
|
||||||
is_current: boolean
|
is_current: boolean;
|
||||||
is_achieved: boolean
|
is_achieved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoyaltyTiersResponse {
|
export interface LoyaltyTiersResponse {
|
||||||
tiers: LoyaltyTierInfo[]
|
tiers: LoyaltyTierInfo[];
|
||||||
current_spent_rubles: number
|
current_spent_rubles: number;
|
||||||
current_tier_name: string | null
|
current_tier_name: string | null;
|
||||||
next_tier_name: string | null
|
next_tier_name: string | null;
|
||||||
next_tier_threshold_rubles: number | null
|
next_tier_threshold_rubles: number | null;
|
||||||
progress_percent: number
|
progress_percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const promoApi = {
|
export const promoApi = {
|
||||||
// Get available promo offers
|
// Get available promo offers
|
||||||
getOffers: async (): Promise<PromoOffer[]> => {
|
getOffers: async (): Promise<PromoOffer[]> => {
|
||||||
const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers')
|
const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get active discount
|
// Get active discount
|
||||||
getActiveDiscount: async (): Promise<ActiveDiscount> => {
|
getActiveDiscount: async (): Promise<ActiveDiscount> => {
|
||||||
const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount')
|
const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get promo group discounts
|
// Get promo group discounts
|
||||||
getGroupDiscounts: async (): Promise<PromoGroupDiscounts> => {
|
getGroupDiscounts: async (): Promise<PromoGroupDiscounts> => {
|
||||||
const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts')
|
const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get loyalty tiers (promo groups with spending thresholds)
|
// Get loyalty tiers (promo groups with spending thresholds)
|
||||||
getLoyaltyTiers: async (): Promise<LoyaltyTiersResponse> => {
|
getLoyaltyTiers: async (): Promise<LoyaltyTiersResponse> => {
|
||||||
const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers')
|
const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Claim a promo offer
|
// Claim a promo offer
|
||||||
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
|
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
|
||||||
const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', {
|
const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', {
|
||||||
offer_id: offerId,
|
offer_id: offerId,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear active discount
|
// Clear active discount
|
||||||
clearActiveDiscount: async (): Promise<{ message: string }> => {
|
clearActiveDiscount: async (): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount')
|
const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,139 +1,140 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// ============== Types ==============
|
// ============== Types ==============
|
||||||
|
|
||||||
export interface PromoOfferUserInfo {
|
export interface PromoOfferUserInfo {
|
||||||
id: number
|
id: number;
|
||||||
telegram_id: number
|
telegram_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
last_name: string | null
|
last_name: string | null;
|
||||||
full_name: string | null
|
full_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferSubscriptionInfo {
|
export interface PromoOfferSubscriptionInfo {
|
||||||
id: number
|
id: number;
|
||||||
status: string
|
status: string;
|
||||||
is_trial: boolean
|
is_trial: boolean;
|
||||||
start_date: string
|
start_date: string;
|
||||||
end_date: string
|
end_date: string;
|
||||||
autopay_enabled: boolean
|
autopay_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOffer {
|
export interface PromoOffer {
|
||||||
id: number
|
id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
subscription_id: number | null
|
subscription_id: number | null;
|
||||||
notification_type: string
|
notification_type: string;
|
||||||
discount_percent: number
|
discount_percent: number;
|
||||||
bonus_amount_kopeks: number
|
bonus_amount_kopeks: number;
|
||||||
expires_at: string
|
expires_at: string;
|
||||||
claimed_at: string | null
|
claimed_at: string | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
effect_type: string
|
effect_type: string;
|
||||||
extra_data: Record<string, any>
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
created_at: string
|
extra_data: Record<string, any>;
|
||||||
updated_at: string
|
created_at: string;
|
||||||
user: PromoOfferUserInfo | null
|
updated_at: string;
|
||||||
subscription: PromoOfferSubscriptionInfo | null
|
user: PromoOfferUserInfo | null;
|
||||||
|
subscription: PromoOfferSubscriptionInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferListResponse {
|
export interface PromoOfferListResponse {
|
||||||
items: PromoOffer[]
|
items: PromoOffer[];
|
||||||
total: number
|
total: number;
|
||||||
limit: number
|
limit: number;
|
||||||
offset: number
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferBroadcastRequest {
|
export interface PromoOfferBroadcastRequest {
|
||||||
notification_type: string
|
notification_type: string;
|
||||||
valid_hours: number
|
valid_hours: number;
|
||||||
discount_percent?: number
|
discount_percent?: number;
|
||||||
bonus_amount_kopeks?: number
|
bonus_amount_kopeks?: number;
|
||||||
effect_type?: string
|
effect_type?: string;
|
||||||
extra_data?: Record<string, any>
|
extra_data?: Record<string, unknown>;
|
||||||
target?: string
|
target?: string;
|
||||||
user_id?: number
|
user_id?: number;
|
||||||
telegram_id?: number
|
telegram_id?: number;
|
||||||
// Telegram notification options
|
// Telegram notification options
|
||||||
send_notification?: boolean
|
send_notification?: boolean;
|
||||||
message_text?: string
|
message_text?: string;
|
||||||
button_text?: string
|
button_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferBroadcastResponse {
|
export interface PromoOfferBroadcastResponse {
|
||||||
created_offers: number
|
created_offers: number;
|
||||||
user_ids: number[]
|
user_ids: number[];
|
||||||
target: string | null
|
target: string | null;
|
||||||
notifications_sent: number
|
notifications_sent: number;
|
||||||
notifications_failed: number
|
notifications_failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferTemplate {
|
export interface PromoOfferTemplate {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
offer_type: string
|
offer_type: string;
|
||||||
message_text: string
|
message_text: string;
|
||||||
button_text: string
|
button_text: string;
|
||||||
valid_hours: number
|
valid_hours: number;
|
||||||
discount_percent: number
|
discount_percent: number;
|
||||||
bonus_amount_kopeks: number
|
bonus_amount_kopeks: number;
|
||||||
active_discount_hours: number | null
|
active_discount_hours: number | null;
|
||||||
test_duration_hours: number | null
|
test_duration_hours: number | null;
|
||||||
test_squad_uuids: string[]
|
test_squad_uuids: string[];
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
created_by: number | null
|
created_by: number | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferTemplateListResponse {
|
export interface PromoOfferTemplateListResponse {
|
||||||
items: PromoOfferTemplate[]
|
items: PromoOfferTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferTemplateUpdateRequest {
|
export interface PromoOfferTemplateUpdateRequest {
|
||||||
name?: string
|
name?: string;
|
||||||
message_text?: string
|
message_text?: string;
|
||||||
button_text?: string
|
button_text?: string;
|
||||||
valid_hours?: number
|
valid_hours?: number;
|
||||||
discount_percent?: number
|
discount_percent?: number;
|
||||||
bonus_amount_kopeks?: number
|
bonus_amount_kopeks?: number;
|
||||||
active_discount_hours?: number
|
active_discount_hours?: number;
|
||||||
test_duration_hours?: number
|
test_duration_hours?: number;
|
||||||
test_squad_uuids?: string[]
|
test_squad_uuids?: string[];
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferLogOfferInfo {
|
export interface PromoOfferLogOfferInfo {
|
||||||
id: number
|
id: number;
|
||||||
notification_type: string | null
|
notification_type: string | null;
|
||||||
discount_percent: number | null
|
discount_percent: number | null;
|
||||||
bonus_amount_kopeks: number | null
|
bonus_amount_kopeks: number | null;
|
||||||
effect_type: string | null
|
effect_type: string | null;
|
||||||
expires_at: string | null
|
expires_at: string | null;
|
||||||
claimed_at: string | null
|
claimed_at: string | null;
|
||||||
is_active: boolean | null
|
is_active: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferLog {
|
export interface PromoOfferLog {
|
||||||
id: number
|
id: number;
|
||||||
user_id: number | null
|
user_id: number | null;
|
||||||
offer_id: number | null
|
offer_id: number | null;
|
||||||
action: string
|
action: string;
|
||||||
source: string | null
|
source: string | null;
|
||||||
percent: number | null
|
percent: number | null;
|
||||||
effect_type: string | null
|
effect_type: string | null;
|
||||||
details: Record<string, any>
|
details: Record<string, unknown>;
|
||||||
created_at: string
|
created_at: string;
|
||||||
user: PromoOfferUserInfo | null
|
user: PromoOfferUserInfo | null;
|
||||||
offer: PromoOfferLogOfferInfo | null
|
offer: PromoOfferLogOfferInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoOfferLogListResponse {
|
export interface PromoOfferLogListResponse {
|
||||||
items: PromoOfferLog[]
|
items: PromoOfferLog[];
|
||||||
total: number
|
total: number;
|
||||||
limit: number
|
limit: number;
|
||||||
offset: number
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target segments for broadcast
|
// Target segments for broadcast
|
||||||
@@ -154,9 +155,9 @@ export const TARGET_SEGMENTS = {
|
|||||||
custom_week: 'Зарегистрированы за неделю',
|
custom_week: 'Зарегистрированы за неделю',
|
||||||
custom_month: 'Зарегистрированы за месяц',
|
custom_month: 'Зарегистрированы за месяц',
|
||||||
custom_active_today: 'Активны сегодня',
|
custom_active_today: 'Активны сегодня',
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
export type TargetSegment = keyof typeof TARGET_SEGMENTS
|
export type TargetSegment = keyof typeof TARGET_SEGMENTS;
|
||||||
|
|
||||||
// Offer type configurations
|
// Offer type configurations
|
||||||
export const OFFER_TYPE_CONFIG = {
|
export const OFFER_TYPE_CONFIG = {
|
||||||
@@ -178,56 +179,61 @@ export const OFFER_TYPE_CONFIG = {
|
|||||||
effect: 'percent_discount',
|
effect: 'percent_discount',
|
||||||
description: 'Скидка для новых пользователей',
|
description: 'Скидка для новых пользователей',
|
||||||
},
|
},
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
export type OfferType = keyof typeof OFFER_TYPE_CONFIG
|
export type OfferType = keyof typeof OFFER_TYPE_CONFIG;
|
||||||
|
|
||||||
// ============== API ==============
|
// ============== API ==============
|
||||||
|
|
||||||
export const promoOffersApi = {
|
export const promoOffersApi = {
|
||||||
// Get list of promo offers
|
// Get list of promo offers
|
||||||
getOffers: async (params?: {
|
getOffers: async (params?: {
|
||||||
limit?: number
|
limit?: number;
|
||||||
offset?: number
|
offset?: number;
|
||||||
user_id?: number
|
user_id?: number;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
}): Promise<PromoOfferListResponse> => {
|
}): Promise<PromoOfferListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/promo-offers', { params })
|
const response = await apiClient.get('/cabinet/admin/promo-offers', { params });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Broadcast offer to multiple users
|
// Broadcast offer to multiple users
|
||||||
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => {
|
broadcastOffer: async (
|
||||||
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data)
|
data: PromoOfferBroadcastRequest,
|
||||||
return response.data
|
): Promise<PromoOfferBroadcastResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get promo offer logs
|
// Get promo offer logs
|
||||||
getLogs: async (params?: {
|
getLogs: async (params?: {
|
||||||
limit?: number
|
limit?: number;
|
||||||
offset?: number
|
offset?: number;
|
||||||
user_id?: number
|
user_id?: number;
|
||||||
action?: string
|
action?: string;
|
||||||
}): Promise<PromoOfferLogListResponse> => {
|
}): Promise<PromoOfferLogListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params })
|
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all templates
|
// Get all templates
|
||||||
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
|
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/promo-offers/templates')
|
const response = await apiClient.get('/cabinet/admin/promo-offers/templates');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single template
|
// Get single template
|
||||||
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
|
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`)
|
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update template
|
// Update template
|
||||||
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => {
|
updateTemplate: async (
|
||||||
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data)
|
id: number,
|
||||||
return response.data
|
data: PromoOfferTemplateUpdateRequest,
|
||||||
|
): Promise<PromoOfferTemplate> => {
|
||||||
|
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,122 +1,127 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// ============== Types ==============
|
// ============== Types ==============
|
||||||
|
|
||||||
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount'
|
export type PromoCodeType =
|
||||||
|
| 'balance'
|
||||||
|
| 'subscription_days'
|
||||||
|
| 'trial_subscription'
|
||||||
|
| 'promo_group'
|
||||||
|
| 'discount';
|
||||||
|
|
||||||
export interface PromoCode {
|
export interface PromoCode {
|
||||||
id: number
|
id: number;
|
||||||
code: string
|
code: string;
|
||||||
type: PromoCodeType
|
type: PromoCodeType;
|
||||||
balance_bonus_kopeks: number
|
balance_bonus_kopeks: number;
|
||||||
balance_bonus_rubles: number
|
balance_bonus_rubles: number;
|
||||||
subscription_days: number
|
subscription_days: number;
|
||||||
max_uses: number
|
max_uses: number;
|
||||||
current_uses: number
|
current_uses: number;
|
||||||
uses_left: number
|
uses_left: number;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
is_valid: boolean
|
is_valid: boolean;
|
||||||
first_purchase_only: boolean
|
first_purchase_only: boolean;
|
||||||
valid_from: string
|
valid_from: string;
|
||||||
valid_until: string | null
|
valid_until: string | null;
|
||||||
promo_group_id: number | null
|
promo_group_id: number | null;
|
||||||
created_by: number | null
|
created_by: number | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoCodeRecentUse {
|
export interface PromoCodeRecentUse {
|
||||||
id: number
|
id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
user_username: string | null
|
user_username: string | null;
|
||||||
user_full_name: string | null
|
user_full_name: string | null;
|
||||||
user_telegram_id: number | null
|
user_telegram_id: number | null;
|
||||||
used_at: string
|
used_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoCodeDetail extends PromoCode {
|
export interface PromoCodeDetail extends PromoCode {
|
||||||
total_uses: number
|
total_uses: number;
|
||||||
today_uses: number
|
today_uses: number;
|
||||||
recent_uses: PromoCodeRecentUse[]
|
recent_uses: PromoCodeRecentUse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoCodeListResponse {
|
export interface PromoCodeListResponse {
|
||||||
items: PromoCode[]
|
items: PromoCode[];
|
||||||
total: number
|
total: number;
|
||||||
limit: number
|
limit: number;
|
||||||
offset: number
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoCodeCreateRequest {
|
export interface PromoCodeCreateRequest {
|
||||||
code: string
|
code: string;
|
||||||
type: PromoCodeType
|
type: PromoCodeType;
|
||||||
balance_bonus_kopeks?: number
|
balance_bonus_kopeks?: number;
|
||||||
subscription_days?: number
|
subscription_days?: number;
|
||||||
max_uses?: number
|
max_uses?: number;
|
||||||
valid_from?: string
|
valid_from?: string;
|
||||||
valid_until?: string | null
|
valid_until?: string | null;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
first_purchase_only?: boolean
|
first_purchase_only?: boolean;
|
||||||
promo_group_id?: number | null
|
promo_group_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoCodeUpdateRequest {
|
export interface PromoCodeUpdateRequest {
|
||||||
code?: string
|
code?: string;
|
||||||
type?: PromoCodeType
|
type?: PromoCodeType;
|
||||||
balance_bonus_kopeks?: number
|
balance_bonus_kopeks?: number;
|
||||||
subscription_days?: number
|
subscription_days?: number;
|
||||||
max_uses?: number
|
max_uses?: number;
|
||||||
valid_from?: string
|
valid_from?: string;
|
||||||
valid_until?: string | null
|
valid_until?: string | null;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
first_purchase_only?: boolean
|
first_purchase_only?: boolean;
|
||||||
promo_group_id?: number | null
|
promo_group_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== PromoGroup Types ==============
|
// ============== PromoGroup Types ==============
|
||||||
|
|
||||||
export interface PromoGroup {
|
export interface PromoGroup {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
server_discount_percent: number
|
server_discount_percent: number;
|
||||||
traffic_discount_percent: number
|
traffic_discount_percent: number;
|
||||||
device_discount_percent: number
|
device_discount_percent: number;
|
||||||
period_discounts: Record<number, number>
|
period_discounts: Record<number, number>;
|
||||||
auto_assign_total_spent_kopeks: number | null
|
auto_assign_total_spent_kopeks: number | null;
|
||||||
apply_discounts_to_addons: boolean
|
apply_discounts_to_addons: boolean;
|
||||||
is_default: boolean
|
is_default: boolean;
|
||||||
members_count: number
|
members_count: number;
|
||||||
created_at: string | null
|
created_at: string | null;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoGroupListResponse {
|
export interface PromoGroupListResponse {
|
||||||
items: PromoGroup[]
|
items: PromoGroup[];
|
||||||
total: number
|
total: number;
|
||||||
limit: number
|
limit: number;
|
||||||
offset: number
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoGroupCreateRequest {
|
export interface PromoGroupCreateRequest {
|
||||||
name: string
|
name: string;
|
||||||
server_discount_percent?: number
|
server_discount_percent?: number;
|
||||||
traffic_discount_percent?: number
|
traffic_discount_percent?: number;
|
||||||
device_discount_percent?: number
|
device_discount_percent?: number;
|
||||||
period_discounts?: Record<number, number>
|
period_discounts?: Record<number, number>;
|
||||||
auto_assign_total_spent_kopeks?: number | null
|
auto_assign_total_spent_kopeks?: number | null;
|
||||||
apply_discounts_to_addons?: boolean
|
apply_discounts_to_addons?: boolean;
|
||||||
is_default?: boolean
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoGroupUpdateRequest {
|
export interface PromoGroupUpdateRequest {
|
||||||
name?: string
|
name?: string;
|
||||||
server_discount_percent?: number
|
server_discount_percent?: number;
|
||||||
traffic_discount_percent?: number
|
traffic_discount_percent?: number;
|
||||||
device_discount_percent?: number
|
device_discount_percent?: number;
|
||||||
period_discounts?: Record<number, number>
|
period_discounts?: Record<number, number>;
|
||||||
auto_assign_total_spent_kopeks?: number | null
|
auto_assign_total_spent_kopeks?: number | null;
|
||||||
apply_discounts_to_addons?: boolean
|
apply_discounts_to_addons?: boolean;
|
||||||
is_default?: boolean
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== API ==============
|
// ============== API ==============
|
||||||
@@ -124,58 +129,58 @@ export interface PromoGroupUpdateRequest {
|
|||||||
export const promocodesApi = {
|
export const promocodesApi = {
|
||||||
// Promocodes
|
// Promocodes
|
||||||
getPromocodes: async (params?: {
|
getPromocodes: async (params?: {
|
||||||
limit?: number
|
limit?: number;
|
||||||
offset?: number
|
offset?: number;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
}): Promise<PromoCodeListResponse> => {
|
}): Promise<PromoCodeListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/promocodes', { params })
|
const response = await apiClient.get('/cabinet/admin/promocodes', { params });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getPromocode: async (id: number): Promise<PromoCodeDetail> => {
|
getPromocode: async (id: number): Promise<PromoCodeDetail> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`)
|
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
|
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/promocodes', data)
|
const response = await apiClient.post('/cabinet/admin/promocodes', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
|
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
|
||||||
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data)
|
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePromocode: async (id: number): Promise<void> => {
|
deletePromocode: async (id: number): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/promocodes/${id}`)
|
await apiClient.delete(`/cabinet/admin/promocodes/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Promo Groups
|
// Promo Groups
|
||||||
getPromoGroups: async (params?: {
|
getPromoGroups: async (params?: {
|
||||||
limit?: number
|
limit?: number;
|
||||||
offset?: number
|
offset?: number;
|
||||||
}): Promise<PromoGroupListResponse> => {
|
}): Promise<PromoGroupListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/promo-groups', { params })
|
const response = await apiClient.get('/cabinet/admin/promo-groups', { params });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getPromoGroup: async (id: number): Promise<PromoGroup> => {
|
getPromoGroup: async (id: number): Promise<PromoGroup> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`)
|
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
|
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/promo-groups', data)
|
const response = await apiClient.post('/cabinet/admin/promo-groups', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
|
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
|
||||||
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data)
|
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePromoGroup: async (id: number): Promise<void> => {
|
deletePromoGroup: async (id: number): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`)
|
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,62 +1,65 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types'
|
import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types';
|
||||||
|
|
||||||
interface ReferralItem {
|
interface ReferralItem {
|
||||||
id: number
|
id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
first_name: string | null
|
first_name: string | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
has_subscription: boolean
|
has_subscription: boolean;
|
||||||
has_paid: boolean
|
has_paid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReferralEarning {
|
interface ReferralEarning {
|
||||||
id: number
|
id: number;
|
||||||
amount_kopeks: number
|
amount_kopeks: number;
|
||||||
amount_rubles: number
|
amount_rubles: number;
|
||||||
reason: string
|
reason: string;
|
||||||
referral_username: string | null
|
referral_username: string | null;
|
||||||
referral_first_name: string | null
|
referral_first_name: string | null;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReferralEarningsList extends PaginatedResponse<ReferralEarning> {
|
interface ReferralEarningsList extends PaginatedResponse<ReferralEarning> {
|
||||||
total_amount_kopeks: number
|
total_amount_kopeks: number;
|
||||||
total_amount_rubles: number
|
total_amount_rubles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const referralApi = {
|
export const referralApi = {
|
||||||
// Get referral info
|
// Get referral info
|
||||||
getReferralInfo: async (): Promise<ReferralInfo> => {
|
getReferralInfo: async (): Promise<ReferralInfo> => {
|
||||||
const response = await apiClient.get<ReferralInfo>('/cabinet/referral')
|
const response = await apiClient.get<ReferralInfo>('/cabinet/referral');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get referral list
|
// Get referral list
|
||||||
getReferralList: async (params?: {
|
getReferralList: async (params?: {
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
}): Promise<PaginatedResponse<ReferralItem>> => {
|
}): Promise<PaginatedResponse<ReferralItem>> => {
|
||||||
const response = await apiClient.get<PaginatedResponse<ReferralItem>>('/cabinet/referral/list', {
|
const response = await apiClient.get<PaginatedResponse<ReferralItem>>(
|
||||||
|
'/cabinet/referral/list',
|
||||||
|
{
|
||||||
params,
|
params,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get referral earnings
|
// Get referral earnings
|
||||||
getReferralEarnings: async (params?: {
|
getReferralEarnings: async (params?: {
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
}): Promise<ReferralEarningsList> => {
|
}): Promise<ReferralEarningsList> => {
|
||||||
const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', {
|
const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', {
|
||||||
params,
|
params,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get referral terms
|
// Get referral terms
|
||||||
getReferralTerms: async (): Promise<ReferralTerms> => {
|
getReferralTerms: async (): Promise<ReferralTerms> => {
|
||||||
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms')
|
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,142 +1,142 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface PromoGroupInfo {
|
export interface PromoGroupInfo {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
is_selected: boolean
|
is_selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerListItem {
|
export interface ServerListItem {
|
||||||
id: number
|
id: number;
|
||||||
squad_uuid: string
|
squad_uuid: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
original_name: string | null
|
original_name: string | null;
|
||||||
country_code: string | null
|
country_code: string | null;
|
||||||
is_available: boolean
|
is_available: boolean;
|
||||||
is_trial_eligible: boolean
|
is_trial_eligible: boolean;
|
||||||
price_kopeks: number
|
price_kopeks: number;
|
||||||
price_rubles: number
|
price_rubles: number;
|
||||||
max_users: number | null
|
max_users: number | null;
|
||||||
current_users: number
|
current_users: number;
|
||||||
sort_order: number
|
sort_order: number;
|
||||||
is_full: boolean
|
is_full: boolean;
|
||||||
availability_status: string
|
availability_status: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerListResponse {
|
export interface ServerListResponse {
|
||||||
servers: ServerListItem[]
|
servers: ServerListItem[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerDetail {
|
export interface ServerDetail {
|
||||||
id: number
|
id: number;
|
||||||
squad_uuid: string
|
squad_uuid: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
original_name: string | null
|
original_name: string | null;
|
||||||
country_code: string | null
|
country_code: string | null;
|
||||||
description: string | null
|
description: string | null;
|
||||||
is_available: boolean
|
is_available: boolean;
|
||||||
is_trial_eligible: boolean
|
is_trial_eligible: boolean;
|
||||||
price_kopeks: number
|
price_kopeks: number;
|
||||||
price_rubles: number
|
price_rubles: number;
|
||||||
max_users: number | null
|
max_users: number | null;
|
||||||
current_users: number
|
current_users: number;
|
||||||
sort_order: number
|
sort_order: number;
|
||||||
is_full: boolean
|
is_full: boolean;
|
||||||
availability_status: string
|
availability_status: string;
|
||||||
promo_groups: PromoGroupInfo[]
|
promo_groups: PromoGroupInfo[];
|
||||||
active_subscriptions: number
|
active_subscriptions: number;
|
||||||
tariffs_using: string[]
|
tariffs_using: string[];
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerUpdateRequest {
|
export interface ServerUpdateRequest {
|
||||||
display_name?: string
|
display_name?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
country_code?: string
|
country_code?: string;
|
||||||
is_available?: boolean
|
is_available?: boolean;
|
||||||
is_trial_eligible?: boolean
|
is_trial_eligible?: boolean;
|
||||||
price_kopeks?: number
|
price_kopeks?: number;
|
||||||
max_users?: number
|
max_users?: number;
|
||||||
sort_order?: number
|
sort_order?: number;
|
||||||
promo_group_ids?: number[]
|
promo_group_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerToggleResponse {
|
export interface ServerToggleResponse {
|
||||||
id: number
|
id: number;
|
||||||
is_available: boolean
|
is_available: boolean;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerTrialToggleResponse {
|
export interface ServerTrialToggleResponse {
|
||||||
id: number
|
id: number;
|
||||||
is_trial_eligible: boolean
|
is_trial_eligible: boolean;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerStats {
|
export interface ServerStats {
|
||||||
id: number
|
id: number;
|
||||||
display_name: string
|
display_name: string;
|
||||||
squad_uuid: string
|
squad_uuid: string;
|
||||||
current_users: number
|
current_users: number;
|
||||||
max_users: number | null
|
max_users: number | null;
|
||||||
active_subscriptions: number
|
active_subscriptions: number;
|
||||||
trial_subscriptions: number
|
trial_subscriptions: number;
|
||||||
usage_percent: number | null
|
usage_percent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerSyncResponse {
|
export interface ServerSyncResponse {
|
||||||
created: number
|
created: number;
|
||||||
updated: number
|
updated: number;
|
||||||
removed: number
|
removed: number;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serversApi = {
|
export const serversApi = {
|
||||||
// Get all servers
|
// Get all servers
|
||||||
getServers: async (includeUnavailable = true): Promise<ServerListResponse> => {
|
getServers: async (includeUnavailable = true): Promise<ServerListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/servers', {
|
const response = await apiClient.get('/cabinet/admin/servers', {
|
||||||
params: { include_unavailable: includeUnavailable }
|
params: { include_unavailable: includeUnavailable },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single server
|
// Get single server
|
||||||
getServer: async (serverId: number): Promise<ServerDetail> => {
|
getServer: async (serverId: number): Promise<ServerDetail> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`)
|
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update server
|
// Update server
|
||||||
updateServer: async (serverId: number, data: ServerUpdateRequest): Promise<ServerDetail> => {
|
updateServer: async (serverId: number, data: ServerUpdateRequest): Promise<ServerDetail> => {
|
||||||
const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data)
|
const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle server availability
|
// Toggle server availability
|
||||||
toggleServer: async (serverId: number): Promise<ServerToggleResponse> => {
|
toggleServer: async (serverId: number): Promise<ServerToggleResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`)
|
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle trial eligibility
|
// Toggle trial eligibility
|
||||||
toggleTrial: async (serverId: number): Promise<ServerTrialToggleResponse> => {
|
toggleTrial: async (serverId: number): Promise<ServerTrialToggleResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`)
|
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get server stats
|
// Get server stats
|
||||||
getServerStats: async (serverId: number): Promise<ServerStats> => {
|
getServerStats: async (serverId: number): Promise<ServerStats> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`)
|
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sync servers with RemnaWave
|
// Sync servers with RemnaWave
|
||||||
syncServers: async (): Promise<ServerSyncResponse> => {
|
syncServers: async (): Promise<ServerSyncResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/servers/sync')
|
const response = await apiClient.post('/cabinet/admin/servers/sync');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,218 +1,256 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { Subscription, RenewalOption, TrafficPackage, TrialInfo, PurchaseOptions, PurchaseSelection, PurchasePreview, AppConfig } from '../types'
|
import type {
|
||||||
|
Subscription,
|
||||||
|
RenewalOption,
|
||||||
|
TrafficPackage,
|
||||||
|
TrialInfo,
|
||||||
|
PurchaseOptions,
|
||||||
|
PurchaseSelection,
|
||||||
|
PurchasePreview,
|
||||||
|
AppConfig,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
export const subscriptionApi = {
|
export const subscriptionApi = {
|
||||||
// Get current subscription
|
// Get current subscription
|
||||||
getSubscription: async (): Promise<Subscription> => {
|
getSubscription: async (): Promise<Subscription> => {
|
||||||
const response = await apiClient.get<Subscription>('/cabinet/subscription')
|
const response = await apiClient.get<Subscription>('/cabinet/subscription');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get renewal options
|
// Get renewal options
|
||||||
getRenewalOptions: async (): Promise<RenewalOption[]> => {
|
getRenewalOptions: async (): Promise<RenewalOption[]> => {
|
||||||
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options')
|
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Renew subscription
|
// Renew subscription
|
||||||
renewSubscription: async (periodDays: number): Promise<{
|
renewSubscription: async (
|
||||||
message: string
|
periodDays: number,
|
||||||
new_end_date: string
|
): Promise<{
|
||||||
amount_paid_kopeks: number
|
message: string;
|
||||||
|
new_end_date: string;
|
||||||
|
amount_paid_kopeks: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/renew', {
|
const response = await apiClient.post('/cabinet/subscription/renew', {
|
||||||
period_days: periodDays,
|
period_days: periodDays,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get traffic packages
|
// Get traffic packages
|
||||||
getTrafficPackages: async (): Promise<TrafficPackage[]> => {
|
getTrafficPackages: async (): Promise<TrafficPackage[]> => {
|
||||||
const response = await apiClient.get<TrafficPackage[]>('/cabinet/subscription/traffic-packages')
|
const response = await apiClient.get<TrafficPackage[]>(
|
||||||
return response.data
|
'/cabinet/subscription/traffic-packages',
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase traffic
|
// Purchase traffic
|
||||||
purchaseTraffic: async (gb: number): Promise<{
|
purchaseTraffic: async (
|
||||||
message: string
|
gb: number,
|
||||||
gb_added: number
|
): Promise<{
|
||||||
amount_paid_kopeks: number
|
message: string;
|
||||||
|
gb_added: number;
|
||||||
|
amount_paid_kopeks: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/traffic', { gb })
|
const response = await apiClient.post('/cabinet/subscription/traffic', { gb });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase devices
|
// Purchase devices
|
||||||
purchaseDevices: async (devices: number): Promise<{
|
purchaseDevices: async (
|
||||||
success: boolean
|
devices: number,
|
||||||
message: string
|
): Promise<{
|
||||||
devices_added: number
|
success: boolean;
|
||||||
new_device_limit: number
|
message: string;
|
||||||
price_kopeks: number
|
devices_added: number;
|
||||||
price_label: string
|
new_device_limit: number;
|
||||||
balance_kopeks: number
|
price_kopeks: number;
|
||||||
balance_label: string
|
price_label: string;
|
||||||
|
balance_kopeks: number;
|
||||||
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices })
|
const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get device purchase price
|
// Get device purchase price
|
||||||
getDevicePrice: async (devices: number = 1): Promise<{
|
getDevicePrice: async (
|
||||||
available: boolean
|
devices: number = 1,
|
||||||
reason?: string
|
): Promise<{
|
||||||
devices?: number
|
available: boolean;
|
||||||
price_per_device_kopeks?: number
|
reason?: string;
|
||||||
price_per_device_label?: string
|
devices?: number;
|
||||||
total_price_kopeks?: number
|
price_per_device_kopeks?: number;
|
||||||
total_price_label?: string
|
price_per_device_label?: string;
|
||||||
current_device_limit?: number
|
total_price_kopeks?: number;
|
||||||
days_left?: number
|
total_price_label?: string;
|
||||||
base_device_price_kopeks?: number
|
current_device_limit?: number;
|
||||||
|
days_left?: number;
|
||||||
|
base_device_price_kopeks?: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/subscription/devices/price', { params: { devices } })
|
const response = await apiClient.get('/cabinet/subscription/devices/price', {
|
||||||
return response.data
|
params: { devices },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update autopay settings
|
// Update autopay settings
|
||||||
updateAutopay: async (enabled: boolean, daysBefore?: number): Promise<{
|
updateAutopay: async (
|
||||||
message: string
|
enabled: boolean,
|
||||||
autopay_enabled: boolean
|
daysBefore?: number,
|
||||||
autopay_days_before: number
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
autopay_enabled: boolean;
|
||||||
|
autopay_days_before: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.patch('/cabinet/subscription/autopay', {
|
const response = await apiClient.patch('/cabinet/subscription/autopay', {
|
||||||
enabled,
|
enabled,
|
||||||
days_before: daysBefore,
|
days_before: daysBefore,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get trial info
|
// Get trial info
|
||||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||||
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial')
|
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Activate trial
|
// Activate trial
|
||||||
activateTrial: async (): Promise<Subscription> => {
|
activateTrial: async (): Promise<Subscription> => {
|
||||||
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial')
|
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get purchase options (periods, servers, traffic, devices)
|
// Get purchase options (periods, servers, traffic, devices)
|
||||||
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
|
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
|
||||||
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options')
|
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Preview purchase price
|
// Preview purchase price
|
||||||
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
|
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
|
||||||
const response = await apiClient.post<PurchasePreview>('/cabinet/subscription/purchase-preview', {
|
const response = await apiClient.post<PurchasePreview>(
|
||||||
|
'/cabinet/subscription/purchase-preview',
|
||||||
|
{
|
||||||
selection,
|
selection,
|
||||||
})
|
},
|
||||||
return response.data
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Submit purchase
|
// Submit purchase
|
||||||
submitPurchase: async (selection: PurchaseSelection): Promise<{
|
submitPurchase: async (
|
||||||
success: boolean
|
selection: PurchaseSelection,
|
||||||
message: string
|
): Promise<{
|
||||||
subscription: Subscription
|
success: boolean;
|
||||||
was_trial_conversion: boolean
|
message: string;
|
||||||
|
subscription: Subscription;
|
||||||
|
was_trial_conversion: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/purchase', {
|
const response = await apiClient.post('/cabinet/subscription/purchase', {
|
||||||
selection,
|
selection,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase tariff (for tariffs mode)
|
// Purchase tariff (for tariffs mode)
|
||||||
purchaseTariff: async (tariffId: number, periodDays: number, trafficGb?: number): Promise<{
|
purchaseTariff: async (
|
||||||
success: boolean
|
tariffId: number,
|
||||||
message: string
|
periodDays: number,
|
||||||
subscription: Subscription
|
trafficGb?: number,
|
||||||
tariff_id: number
|
): Promise<{
|
||||||
tariff_name: string
|
success: boolean;
|
||||||
balance_kopeks: number
|
message: string;
|
||||||
balance_label: string
|
subscription: Subscription;
|
||||||
|
tariff_id: number;
|
||||||
|
tariff_name: string;
|
||||||
|
balance_kopeks: number;
|
||||||
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/purchase-tariff', {
|
const response = await apiClient.post('/cabinet/subscription/purchase-tariff', {
|
||||||
tariff_id: tariffId,
|
tariff_id: tariffId,
|
||||||
period_days: periodDays,
|
period_days: periodDays,
|
||||||
traffic_gb: trafficGb,
|
traffic_gb: trafficGb,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get app config for connection
|
// Get app config for connection
|
||||||
getAppConfig: async (): Promise<AppConfig> => {
|
getAppConfig: async (): Promise<AppConfig> => {
|
||||||
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config')
|
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available countries/servers
|
// Get available countries/servers
|
||||||
getCountries: async (): Promise<{
|
getCountries: async (): Promise<{
|
||||||
countries: Array<{
|
countries: Array<{
|
||||||
uuid: string
|
uuid: string;
|
||||||
name: string
|
name: string;
|
||||||
country_code: string | null
|
country_code: string | null;
|
||||||
base_price_kopeks: number
|
base_price_kopeks: number;
|
||||||
price_kopeks: number
|
price_kopeks: number;
|
||||||
price_per_month_kopeks: number
|
price_per_month_kopeks: number;
|
||||||
price_rubles: number
|
price_rubles: number;
|
||||||
is_available: boolean
|
is_available: boolean;
|
||||||
is_connected: boolean
|
is_connected: boolean;
|
||||||
has_discount: boolean
|
has_discount: boolean;
|
||||||
discount_percent: number
|
discount_percent: number;
|
||||||
}>
|
}>;
|
||||||
connected_count: number
|
connected_count: number;
|
||||||
has_subscription: boolean
|
has_subscription: boolean;
|
||||||
days_left: number
|
days_left: number;
|
||||||
discount_percent: number
|
discount_percent: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/subscription/countries')
|
const response = await apiClient.get('/cabinet/subscription/countries');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update countries/servers
|
// Update countries/servers
|
||||||
updateCountries: async (countries: string[]): Promise<{
|
updateCountries: async (
|
||||||
message: string
|
countries: string[],
|
||||||
added: string[]
|
): Promise<{
|
||||||
removed: string[]
|
message: string;
|
||||||
amount_paid_kopeks: number
|
added: string[];
|
||||||
connected_squads: string[]
|
removed: string[];
|
||||||
|
amount_paid_kopeks: number;
|
||||||
|
connected_squads: string[];
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/countries', { countries })
|
const response = await apiClient.post('/cabinet/subscription/countries', { countries });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get connection link and instructions
|
// Get connection link and instructions
|
||||||
getConnectionLink: async (): Promise<{
|
getConnectionLink: async (): Promise<{
|
||||||
subscription_url: string | null
|
subscription_url: string | null;
|
||||||
display_link: string | null
|
display_link: string | null;
|
||||||
happ_redirect_link: string | null
|
happ_redirect_link: string | null;
|
||||||
happ_scheme_link: string | null
|
happ_scheme_link: string | null;
|
||||||
connect_mode: string
|
connect_mode: string;
|
||||||
hide_link: boolean
|
hide_link: boolean;
|
||||||
instructions: {
|
instructions: {
|
||||||
steps: string[]
|
steps: string[];
|
||||||
}
|
};
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/subscription/connection-link')
|
const response = await apiClient.get('/cabinet/subscription/connection-link');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get hApp download links
|
// Get hApp download links
|
||||||
getHappDownloads: async (): Promise<{
|
getHappDownloads: async (): Promise<{
|
||||||
platforms: Record<string, {
|
platforms: Record<
|
||||||
name: string
|
string,
|
||||||
icon: string
|
{
|
||||||
link: string
|
name: string;
|
||||||
}>
|
icon: string;
|
||||||
happ_enabled: boolean
|
link: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
happ_enabled: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/subscription/happ-downloads')
|
const response = await apiClient.get('/cabinet/subscription/happ-downloads');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Device Management ============
|
// ============ Device Management ============
|
||||||
@@ -220,130 +258,140 @@ export const subscriptionApi = {
|
|||||||
// Get connected devices
|
// Get connected devices
|
||||||
getDevices: async (): Promise<{
|
getDevices: async (): Promise<{
|
||||||
devices: Array<{
|
devices: Array<{
|
||||||
hwid: string
|
hwid: string;
|
||||||
platform: string
|
platform: string;
|
||||||
device_model: string
|
device_model: string;
|
||||||
created_at: string | null
|
created_at: string | null;
|
||||||
}>
|
}>;
|
||||||
total: number
|
total: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/subscription/devices')
|
const response = await apiClient.get('/cabinet/subscription/devices');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete a specific device
|
// Delete a specific device
|
||||||
deleteDevice: async (hwid: string): Promise<{
|
deleteDevice: async (
|
||||||
success: boolean
|
hwid: string,
|
||||||
message: string
|
): Promise<{
|
||||||
deleted_hwid: string
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
deleted_hwid: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.delete(`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`)
|
const response = await apiClient.delete(
|
||||||
return response.data
|
`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete all devices
|
// Delete all devices
|
||||||
deleteAllDevices: async (): Promise<{
|
deleteAllDevices: async (): Promise<{
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
deleted_count: number
|
deleted_count: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.delete('/cabinet/subscription/devices')
|
const response = await apiClient.delete('/cabinet/subscription/devices');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Tariff Switch ============
|
// ============ Tariff Switch ============
|
||||||
|
|
||||||
// Preview tariff switch cost
|
// Preview tariff switch cost
|
||||||
previewTariffSwitch: async (tariffId: number): Promise<{
|
previewTariffSwitch: async (
|
||||||
can_switch: boolean
|
tariffId: number,
|
||||||
current_tariff_id: number | null
|
): Promise<{
|
||||||
current_tariff_name: string | null
|
can_switch: boolean;
|
||||||
new_tariff_id: number
|
current_tariff_id: number | null;
|
||||||
new_tariff_name: string
|
current_tariff_name: string | null;
|
||||||
remaining_days: number
|
new_tariff_id: number;
|
||||||
upgrade_cost_kopeks: number
|
new_tariff_name: string;
|
||||||
upgrade_cost_label: string
|
remaining_days: number;
|
||||||
balance_kopeks: number
|
upgrade_cost_kopeks: number;
|
||||||
balance_label: string
|
upgrade_cost_label: string;
|
||||||
has_enough_balance: boolean
|
balance_kopeks: number;
|
||||||
missing_amount_kopeks: number
|
balance_label: string;
|
||||||
missing_amount_label: string
|
has_enough_balance: boolean;
|
||||||
is_upgrade: boolean
|
missing_amount_kopeks: number;
|
||||||
|
missing_amount_label: string;
|
||||||
|
is_upgrade: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', {
|
const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', {
|
||||||
tariff_id: tariffId,
|
tariff_id: tariffId,
|
||||||
period_days: 30, // Default period for switch
|
period_days: 30, // Default period for switch
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Switch to a different tariff
|
// Switch to a different tariff
|
||||||
switchTariff: async (tariffId: number): Promise<{
|
switchTariff: async (
|
||||||
success: boolean
|
tariffId: number,
|
||||||
message: string
|
): Promise<{
|
||||||
subscription: Subscription
|
success: boolean;
|
||||||
old_tariff_name: string
|
message: string;
|
||||||
new_tariff_id: number
|
subscription: Subscription;
|
||||||
new_tariff_name: string
|
old_tariff_name: string;
|
||||||
charged_kopeks: number
|
new_tariff_id: number;
|
||||||
balance_kopeks: number
|
new_tariff_name: string;
|
||||||
balance_label: string
|
charged_kopeks: number;
|
||||||
|
balance_kopeks: number;
|
||||||
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/tariff/switch', {
|
const response = await apiClient.post('/cabinet/subscription/tariff/switch', {
|
||||||
tariff_id: tariffId,
|
tariff_id: tariffId,
|
||||||
period_days: 30,
|
period_days: 30,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Subscription Pause (Daily Tariffs) ============
|
// ============ Subscription Pause (Daily Tariffs) ============
|
||||||
|
|
||||||
// Toggle pause/resume for daily subscription
|
// Toggle pause/resume for daily subscription
|
||||||
togglePause: async (): Promise<{
|
togglePause: async (): Promise<{
|
||||||
success: boolean
|
success: boolean;
|
||||||
message: string
|
message: string;
|
||||||
is_paused: boolean
|
is_paused: boolean;
|
||||||
balance_kopeks: number
|
balance_kopeks: number;
|
||||||
balance_label: string
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/pause')
|
const response = await apiClient.post('/cabinet/subscription/pause');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============ Traffic Switch ============
|
// ============ Traffic Switch ============
|
||||||
|
|
||||||
// Switch to a different traffic package
|
// Switch to a different traffic package
|
||||||
switchTraffic: async (gb: number): Promise<{
|
switchTraffic: async (
|
||||||
success: boolean
|
gb: number,
|
||||||
message: string
|
): Promise<{
|
||||||
old_traffic_gb: number
|
success: boolean;
|
||||||
new_traffic_gb: number
|
message: string;
|
||||||
charged_kopeks: number
|
old_traffic_gb: number;
|
||||||
balance_kopeks: number
|
new_traffic_gb: number;
|
||||||
balance_label: string
|
charged_kopeks: number;
|
||||||
|
balance_kopeks: number;
|
||||||
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.put('/cabinet/subscription/traffic', { gb })
|
const response = await apiClient.put('/cabinet/subscription/traffic', { gb });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds)
|
// Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds)
|
||||||
refreshTraffic: async (): Promise<{
|
refreshTraffic: async (): Promise<{
|
||||||
success: boolean
|
success: boolean;
|
||||||
cached: boolean
|
cached: boolean;
|
||||||
rate_limited?: boolean
|
rate_limited?: boolean;
|
||||||
retry_after_seconds?: number
|
retry_after_seconds?: number;
|
||||||
source?: string
|
source?: string;
|
||||||
traffic_used_bytes: number
|
traffic_used_bytes: number;
|
||||||
traffic_used_gb: number
|
traffic_used_gb: number;
|
||||||
traffic_limit_bytes: number
|
traffic_limit_bytes: number;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
traffic_used_percent: number
|
traffic_used_percent: number;
|
||||||
is_unlimited: boolean
|
is_unlimited: boolean;
|
||||||
lifetime_used_bytes?: number
|
lifetime_used_bytes?: number;
|
||||||
lifetime_used_gb?: number
|
lifetime_used_gb?: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/refresh-traffic')
|
const response = await apiClient.post('/cabinet/subscription/refresh-traffic');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,239 +1,239 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface PeriodPrice {
|
export interface PeriodPrice {
|
||||||
days: number
|
days: number;
|
||||||
price_kopeks: number
|
price_kopeks: number;
|
||||||
price_rubles?: number
|
price_rubles?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerTrafficLimit {
|
export interface ServerTrafficLimit {
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
id: number
|
id: number;
|
||||||
squad_uuid: string
|
squad_uuid: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
country_code: string | null
|
country_code: string | null;
|
||||||
is_selected: boolean
|
is_selected: boolean;
|
||||||
traffic_limit_gb?: number | null
|
traffic_limit_gb?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoGroupInfo {
|
export interface PromoGroupInfo {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
is_selected: boolean
|
is_selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffListItem {
|
export interface TariffListItem {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
is_trial_available: boolean
|
is_trial_available: boolean;
|
||||||
is_daily: boolean
|
is_daily: boolean;
|
||||||
daily_price_kopeks: number
|
daily_price_kopeks: number;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
tier_level: number
|
tier_level: number;
|
||||||
display_order: number
|
display_order: number;
|
||||||
servers_count: number
|
servers_count: number;
|
||||||
subscriptions_count: number
|
subscriptions_count: number;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffListResponse {
|
export interface TariffListResponse {
|
||||||
tariffs: TariffListItem[]
|
tariffs: TariffListItem[];
|
||||||
total: number
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffDetail {
|
export interface TariffDetail {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
description: string | null
|
description: string | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
is_trial_available: boolean
|
is_trial_available: boolean;
|
||||||
traffic_limit_gb: number
|
traffic_limit_gb: number;
|
||||||
device_limit: number
|
device_limit: number;
|
||||||
device_price_kopeks: number | null
|
device_price_kopeks: number | null;
|
||||||
max_device_limit: number | null
|
max_device_limit: number | null;
|
||||||
tier_level: number
|
tier_level: number;
|
||||||
display_order: number
|
display_order: number;
|
||||||
period_prices: PeriodPrice[]
|
period_prices: PeriodPrice[];
|
||||||
allowed_squads: string[]
|
allowed_squads: string[];
|
||||||
server_traffic_limits: Record<string, ServerTrafficLimit>
|
server_traffic_limits: Record<string, ServerTrafficLimit>;
|
||||||
servers: ServerInfo[]
|
servers: ServerInfo[];
|
||||||
promo_groups: PromoGroupInfo[]
|
promo_groups: PromoGroupInfo[];
|
||||||
subscriptions_count: number
|
subscriptions_count: number;
|
||||||
// Произвольное количество дней
|
// Произвольное количество дней
|
||||||
custom_days_enabled: boolean
|
custom_days_enabled: boolean;
|
||||||
price_per_day_kopeks: number
|
price_per_day_kopeks: number;
|
||||||
min_days: number
|
min_days: number;
|
||||||
max_days: number
|
max_days: number;
|
||||||
// Произвольный трафик при покупке
|
// Произвольный трафик при покупке
|
||||||
custom_traffic_enabled: boolean
|
custom_traffic_enabled: boolean;
|
||||||
traffic_price_per_gb_kopeks: number
|
traffic_price_per_gb_kopeks: number;
|
||||||
min_traffic_gb: number
|
min_traffic_gb: number;
|
||||||
max_traffic_gb: number
|
max_traffic_gb: number;
|
||||||
// Докупка трафика
|
// Докупка трафика
|
||||||
traffic_topup_enabled: boolean
|
traffic_topup_enabled: boolean;
|
||||||
traffic_topup_packages: Record<string, number>
|
traffic_topup_packages: Record<string, number>;
|
||||||
max_topup_traffic_gb: number
|
max_topup_traffic_gb: number;
|
||||||
// Дневной тариф
|
// Дневной тариф
|
||||||
is_daily: boolean
|
is_daily: boolean;
|
||||||
daily_price_kopeks: number
|
daily_price_kopeks: number;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode: string | null // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffCreateRequest {
|
export interface TariffCreateRequest {
|
||||||
name: string
|
name: string;
|
||||||
description?: string
|
description?: string;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
traffic_limit_gb?: number
|
traffic_limit_gb?: number;
|
||||||
device_limit?: number
|
device_limit?: number;
|
||||||
device_price_kopeks?: number
|
device_price_kopeks?: number;
|
||||||
max_device_limit?: number
|
max_device_limit?: number;
|
||||||
tier_level?: number
|
tier_level?: number;
|
||||||
period_prices?: PeriodPrice[]
|
period_prices?: PeriodPrice[];
|
||||||
allowed_squads?: string[]
|
allowed_squads?: string[];
|
||||||
server_traffic_limits?: Record<string, ServerTrafficLimit>
|
server_traffic_limits?: Record<string, ServerTrafficLimit>;
|
||||||
promo_group_ids?: number[]
|
promo_group_ids?: number[];
|
||||||
// Произвольное количество дней
|
// Произвольное количество дней
|
||||||
custom_days_enabled?: boolean
|
custom_days_enabled?: boolean;
|
||||||
price_per_day_kopeks?: number
|
price_per_day_kopeks?: number;
|
||||||
min_days?: number
|
min_days?: number;
|
||||||
max_days?: number
|
max_days?: number;
|
||||||
// Произвольный трафик при покупке
|
// Произвольный трафик при покупке
|
||||||
custom_traffic_enabled?: boolean
|
custom_traffic_enabled?: boolean;
|
||||||
traffic_price_per_gb_kopeks?: number
|
traffic_price_per_gb_kopeks?: number;
|
||||||
min_traffic_gb?: number
|
min_traffic_gb?: number;
|
||||||
max_traffic_gb?: number
|
max_traffic_gb?: number;
|
||||||
// Докупка трафика
|
// Докупка трафика
|
||||||
traffic_topup_enabled?: boolean
|
traffic_topup_enabled?: boolean;
|
||||||
traffic_topup_packages?: Record<string, number>
|
traffic_topup_packages?: Record<string, number>;
|
||||||
max_topup_traffic_gb?: number
|
max_topup_traffic_gb?: number;
|
||||||
// Дневной тариф
|
// Дневной тариф
|
||||||
is_daily?: boolean
|
is_daily?: boolean;
|
||||||
daily_price_kopeks?: number
|
daily_price_kopeks?: number;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode?: string | null
|
traffic_reset_mode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffUpdateRequest {
|
export interface TariffUpdateRequest {
|
||||||
name?: string
|
name?: string;
|
||||||
description?: string
|
description?: string;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
traffic_limit_gb?: number
|
traffic_limit_gb?: number;
|
||||||
device_limit?: number
|
device_limit?: number;
|
||||||
device_price_kopeks?: number
|
device_price_kopeks?: number;
|
||||||
max_device_limit?: number
|
max_device_limit?: number;
|
||||||
tier_level?: number
|
tier_level?: number;
|
||||||
display_order?: number
|
display_order?: number;
|
||||||
period_prices?: PeriodPrice[]
|
period_prices?: PeriodPrice[];
|
||||||
allowed_squads?: string[]
|
allowed_squads?: string[];
|
||||||
server_traffic_limits?: Record<string, ServerTrafficLimit>
|
server_traffic_limits?: Record<string, ServerTrafficLimit>;
|
||||||
promo_group_ids?: number[]
|
promo_group_ids?: number[];
|
||||||
// Произвольное количество дней
|
// Произвольное количество дней
|
||||||
custom_days_enabled?: boolean
|
custom_days_enabled?: boolean;
|
||||||
price_per_day_kopeks?: number
|
price_per_day_kopeks?: number;
|
||||||
min_days?: number
|
min_days?: number;
|
||||||
max_days?: number
|
max_days?: number;
|
||||||
// Произвольный трафик при покупке
|
// Произвольный трафик при покупке
|
||||||
custom_traffic_enabled?: boolean
|
custom_traffic_enabled?: boolean;
|
||||||
traffic_price_per_gb_kopeks?: number
|
traffic_price_per_gb_kopeks?: number;
|
||||||
min_traffic_gb?: number
|
min_traffic_gb?: number;
|
||||||
max_traffic_gb?: number
|
max_traffic_gb?: number;
|
||||||
// Докупка трафика
|
// Докупка трафика
|
||||||
traffic_topup_enabled?: boolean
|
traffic_topup_enabled?: boolean;
|
||||||
traffic_topup_packages?: Record<string, number>
|
traffic_topup_packages?: Record<string, number>;
|
||||||
max_topup_traffic_gb?: number
|
max_topup_traffic_gb?: number;
|
||||||
// Дневной тариф
|
// Дневной тариф
|
||||||
is_daily?: boolean
|
is_daily?: boolean;
|
||||||
daily_price_kopeks?: number
|
daily_price_kopeks?: number;
|
||||||
// Режим сброса трафика
|
// Режим сброса трафика
|
||||||
traffic_reset_mode?: string | null
|
traffic_reset_mode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffToggleResponse {
|
export interface TariffToggleResponse {
|
||||||
id: number
|
id: number;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffTrialResponse {
|
export interface TariffTrialResponse {
|
||||||
id: number
|
id: number;
|
||||||
is_trial_available: boolean
|
is_trial_available: boolean;
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffStats {
|
export interface TariffStats {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
subscriptions_count: number
|
subscriptions_count: number;
|
||||||
active_subscriptions: number
|
active_subscriptions: number;
|
||||||
trial_subscriptions: number
|
trial_subscriptions: number;
|
||||||
revenue_kopeks: number
|
revenue_kopeks: number;
|
||||||
revenue_rubles: number
|
revenue_rubles: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tariffsApi = {
|
export const tariffsApi = {
|
||||||
// Get all tariffs
|
// Get all tariffs
|
||||||
getTariffs: async (includeInactive = true): Promise<TariffListResponse> => {
|
getTariffs: async (includeInactive = true): Promise<TariffListResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/tariffs', {
|
const response = await apiClient.get('/cabinet/admin/tariffs', {
|
||||||
params: { include_inactive: includeInactive }
|
params: { include_inactive: includeInactive },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single tariff
|
// Get single tariff
|
||||||
getTariff: async (tariffId: number): Promise<TariffDetail> => {
|
getTariff: async (tariffId: number): Promise<TariffDetail> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`)
|
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create new tariff
|
// Create new tariff
|
||||||
createTariff: async (data: TariffCreateRequest): Promise<TariffDetail> => {
|
createTariff: async (data: TariffCreateRequest): Promise<TariffDetail> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/tariffs', data)
|
const response = await apiClient.post('/cabinet/admin/tariffs', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update tariff
|
// Update tariff
|
||||||
updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise<TariffDetail> => {
|
updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise<TariffDetail> => {
|
||||||
const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data)
|
const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete tariff
|
// Delete tariff
|
||||||
deleteTariff: async (tariffId: number): Promise<{ message: string }> => {
|
deleteTariff: async (tariffId: number): Promise<{ message: string }> => {
|
||||||
const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`)
|
const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle tariff active status
|
// Toggle tariff active status
|
||||||
toggleTariff: async (tariffId: number): Promise<TariffToggleResponse> => {
|
toggleTariff: async (tariffId: number): Promise<TariffToggleResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`)
|
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle trial status
|
// Toggle trial status
|
||||||
toggleTrial: async (tariffId: number): Promise<TariffTrialResponse> => {
|
toggleTrial: async (tariffId: number): Promise<TariffTrialResponse> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`)
|
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get tariff stats
|
// Get tariff stats
|
||||||
getTariffStats: async (tariffId: number): Promise<TariffStats> => {
|
getTariffStats: async (tariffId: number): Promise<TariffStats> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`)
|
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get available servers for selection
|
// Get available servers for selection
|
||||||
getAvailableServers: async (): Promise<ServerInfo[]> => {
|
getAvailableServers: async (): Promise<ServerInfo[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers')
|
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,43 +1,48 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import { ThemeSettings, DEFAULT_THEME_COLORS, EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
import {
|
||||||
|
ThemeSettings,
|
||||||
|
DEFAULT_THEME_COLORS,
|
||||||
|
EnabledThemes,
|
||||||
|
DEFAULT_ENABLED_THEMES,
|
||||||
|
} from '../types/theme';
|
||||||
|
|
||||||
export const themeColorsApi = {
|
export const themeColorsApi = {
|
||||||
// Get current theme colors (public, no auth required)
|
// Get current theme colors (public, no auth required)
|
||||||
getColors: async (): Promise<ThemeSettings> => {
|
getColors: async (): Promise<ThemeSettings> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<ThemeSettings>('/cabinet/branding/colors')
|
const response = await apiClient.get<ThemeSettings>('/cabinet/branding/colors');
|
||||||
return response.data
|
return response.data;
|
||||||
} catch {
|
} catch {
|
||||||
// Return default colors if endpoint not available
|
// Return default colors if endpoint not available
|
||||||
return DEFAULT_THEME_COLORS
|
return DEFAULT_THEME_COLORS;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update theme colors (admin only)
|
// Update theme colors (admin only)
|
||||||
updateColors: async (colors: Partial<ThemeSettings>): Promise<ThemeSettings> => {
|
updateColors: async (colors: Partial<ThemeSettings>): Promise<ThemeSettings> => {
|
||||||
const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors)
|
const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset to default colors (admin only)
|
// Reset to default colors (admin only)
|
||||||
resetColors: async (): Promise<ThemeSettings> => {
|
resetColors: async (): Promise<ThemeSettings> => {
|
||||||
const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset')
|
const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get enabled themes (public, no auth required)
|
// Get enabled themes (public, no auth required)
|
||||||
getEnabledThemes: async (): Promise<EnabledThemes> => {
|
getEnabledThemes: async (): Promise<EnabledThemes> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes')
|
const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes');
|
||||||
return response.data
|
return response.data;
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_ENABLED_THEMES
|
return DEFAULT_ENABLED_THEMES;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update enabled themes (admin only)
|
// Update enabled themes (admin only)
|
||||||
updateEnabledThemes: async (themes: Partial<EnabledThemes>): Promise<EnabledThemes> => {
|
updateEnabledThemes: async (themes: Partial<EnabledThemes>): Promise<EnabledThemes> => {
|
||||||
const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes)
|
const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,64 +1,80 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { TicketNotificationList, UnreadCountResponse } from '../types'
|
import type { TicketNotificationList, UnreadCountResponse } from '../types';
|
||||||
|
|
||||||
export const ticketNotificationsApi = {
|
export const ticketNotificationsApi = {
|
||||||
// User notifications
|
// User notifications
|
||||||
getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => {
|
getNotifications: async (
|
||||||
|
unreadOnly = false,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<TicketNotificationList> => {
|
||||||
const response = await apiClient.get('/cabinet/tickets/notifications', {
|
const response = await apiClient.get('/cabinet/tickets/notifications', {
|
||||||
params: { unread_only: unreadOnly, limit, offset }
|
params: { unread_only: unreadOnly, limit, offset },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUnreadCount: async (): Promise<UnreadCountResponse> => {
|
getUnreadCount: async (): Promise<UnreadCountResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count')
|
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
|
markAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
|
||||||
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`)
|
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
|
markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
|
||||||
const response = await apiClient.post('/cabinet/tickets/notifications/read-all')
|
const response = await apiClient.post('/cabinet/tickets/notifications/read-all');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => {
|
markTicketAsRead: async (
|
||||||
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`)
|
ticketId: number,
|
||||||
return response.data
|
): Promise<{ success: boolean; marked_count: number }> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin notifications
|
// Admin notifications
|
||||||
getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => {
|
getAdminNotifications: async (
|
||||||
const params: Record<string, unknown> = { limit, offset }
|
unreadOnly = false,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<TicketNotificationList> => {
|
||||||
|
const params: Record<string, unknown> = { limit, offset };
|
||||||
if (unreadOnly) {
|
if (unreadOnly) {
|
||||||
params.unread_only = true
|
params.unread_only = true;
|
||||||
}
|
}
|
||||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params })
|
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params });
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
|
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count')
|
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
|
markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
|
||||||
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`)
|
const response = await apiClient.post(
|
||||||
return response.data
|
`/cabinet/admin/tickets/notifications/${notificationId}/read`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
|
markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
|
||||||
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all')
|
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => {
|
markAdminTicketAsRead: async (
|
||||||
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`)
|
ticketId: number,
|
||||||
return response.data
|
): Promise<{ success: boolean; marked_count: number }> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ticketNotificationsApi
|
export default ticketNotificationsApi;
|
||||||
|
|||||||
@@ -1,84 +1,84 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types'
|
import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types';
|
||||||
|
|
||||||
// Media upload response type
|
// Media upload response type
|
||||||
interface MediaUploadResponse {
|
interface MediaUploadResponse {
|
||||||
media_type: string
|
media_type: string;
|
||||||
file_id: string
|
file_id: string;
|
||||||
file_unique_id: string | null
|
file_unique_id: string | null;
|
||||||
media_url: string
|
media_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media parameters for ticket messages
|
// Media parameters for ticket messages
|
||||||
interface MediaParams {
|
interface MediaParams {
|
||||||
media_type?: string
|
media_type?: string;
|
||||||
media_file_id?: string
|
media_file_id?: string;
|
||||||
media_caption?: string
|
media_caption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ticketsApi = {
|
export const ticketsApi = {
|
||||||
// Get tickets list
|
// Get tickets list
|
||||||
getTickets: async (params?: {
|
getTickets: async (params?: {
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
status?: string
|
status?: string;
|
||||||
}): Promise<PaginatedResponse<Ticket>> => {
|
}): Promise<PaginatedResponse<Ticket>> => {
|
||||||
const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', {
|
const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', {
|
||||||
params,
|
params,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create ticket with optional media
|
// Create ticket with optional media
|
||||||
createTicket: async (
|
createTicket: async (
|
||||||
title: string,
|
title: string,
|
||||||
message: string,
|
message: string,
|
||||||
media?: MediaParams
|
media?: MediaParams,
|
||||||
): Promise<TicketDetail> => {
|
): Promise<TicketDetail> => {
|
||||||
const response = await apiClient.post<TicketDetail>('/cabinet/tickets', {
|
const response = await apiClient.post<TicketDetail>('/cabinet/tickets', {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
...media,
|
...media,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get ticket detail
|
// Get ticket detail
|
||||||
getTicket: async (ticketId: number): Promise<TicketDetail> => {
|
getTicket: async (ticketId: number): Promise<TicketDetail> => {
|
||||||
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`)
|
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add message to ticket with optional media
|
// Add message to ticket with optional media
|
||||||
addMessage: async (
|
addMessage: async (
|
||||||
ticketId: number,
|
ticketId: number,
|
||||||
message: string,
|
message: string,
|
||||||
media?: MediaParams
|
media?: MediaParams,
|
||||||
): Promise<TicketMessage> => {
|
): Promise<TicketMessage> => {
|
||||||
const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, {
|
const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, {
|
||||||
message,
|
message,
|
||||||
...media,
|
...media,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Upload media file for tickets
|
// Upload media file for tickets
|
||||||
uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => {
|
uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('file', file)
|
formData.append('file', file);
|
||||||
formData.append('media_type', mediaType)
|
formData.append('media_type', mediaType);
|
||||||
|
|
||||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get media URL for display
|
// Get media URL for display
|
||||||
getMediaUrl: (fileId: string): string => {
|
getMediaUrl: (fileId: string): string => {
|
||||||
const baseUrl = import.meta.env.VITE_API_URL || ''
|
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||||
return `${baseUrl}/cabinet/media/${fileId}`
|
return `${baseUrl}/cabinet/media/${fileId}`;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
344
src/api/wheel.ts
344
src/api/wheel.ts
@@ -1,182 +1,182 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client';
|
||||||
|
|
||||||
// ==================== TYPES ====================
|
// ==================== TYPES ====================
|
||||||
|
|
||||||
export interface WheelPrize {
|
export interface WheelPrize {
|
||||||
id: number
|
id: number;
|
||||||
display_name: string
|
display_name: string;
|
||||||
emoji: string
|
emoji: string;
|
||||||
color: string
|
color: string;
|
||||||
prize_type: string
|
prize_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WheelConfig {
|
export interface WheelConfig {
|
||||||
is_enabled: boolean
|
is_enabled: boolean;
|
||||||
name: string
|
name: string;
|
||||||
spin_cost_stars: number | null
|
spin_cost_stars: number | null;
|
||||||
spin_cost_days: number | null
|
spin_cost_days: number | null;
|
||||||
spin_cost_stars_enabled: boolean
|
spin_cost_stars_enabled: boolean;
|
||||||
spin_cost_days_enabled: boolean
|
spin_cost_days_enabled: boolean;
|
||||||
prizes: WheelPrize[]
|
prizes: WheelPrize[];
|
||||||
daily_limit: number
|
daily_limit: number;
|
||||||
user_spins_today: number
|
user_spins_today: number;
|
||||||
can_spin: boolean
|
can_spin: boolean;
|
||||||
can_spin_reason: string | null
|
can_spin_reason: string | null;
|
||||||
can_pay_stars: boolean
|
can_pay_stars: boolean;
|
||||||
can_pay_days: boolean
|
can_pay_days: boolean;
|
||||||
user_balance_kopeks: number
|
user_balance_kopeks: number;
|
||||||
required_balance_kopeks: number
|
required_balance_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinAvailability {
|
export interface SpinAvailability {
|
||||||
can_spin: boolean
|
can_spin: boolean;
|
||||||
reason: string | null
|
reason: string | null;
|
||||||
spins_remaining_today: number
|
spins_remaining_today: number;
|
||||||
can_pay_stars: boolean
|
can_pay_stars: boolean;
|
||||||
can_pay_days: boolean
|
can_pay_days: boolean;
|
||||||
min_subscription_days: number
|
min_subscription_days: number;
|
||||||
user_subscription_days: number
|
user_subscription_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinResult {
|
export interface SpinResult {
|
||||||
success: boolean
|
success: boolean;
|
||||||
prize_id: number | null
|
prize_id: number | null;
|
||||||
prize_type: string | null
|
prize_type: string | null;
|
||||||
prize_value: number
|
prize_value: number;
|
||||||
prize_display_name: string
|
prize_display_name: string;
|
||||||
emoji: string
|
emoji: string;
|
||||||
color: string
|
color: string;
|
||||||
rotation_degrees: number
|
rotation_degrees: number;
|
||||||
message: string
|
message: string;
|
||||||
promocode: string | null
|
promocode: string | null;
|
||||||
error: string | null
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinHistoryItem {
|
export interface SpinHistoryItem {
|
||||||
id: number
|
id: number;
|
||||||
payment_type: string
|
payment_type: string;
|
||||||
payment_amount: number
|
payment_amount: number;
|
||||||
prize_type: string
|
prize_type: string;
|
||||||
prize_value: number
|
prize_value: number;
|
||||||
prize_display_name: string
|
prize_display_name: string;
|
||||||
emoji: string
|
emoji: string;
|
||||||
color: string
|
color: string;
|
||||||
prize_value_kopeks: number
|
prize_value_kopeks: number;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinHistoryResponse {
|
export interface SpinHistoryResponse {
|
||||||
items: SpinHistoryItem[]
|
items: SpinHistoryItem[];
|
||||||
total: number
|
total: number;
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
pages: number
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StarsInvoiceResponse {
|
export interface StarsInvoiceResponse {
|
||||||
invoice_url: string
|
invoice_url: string;
|
||||||
stars_amount: number
|
stars_amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin types
|
// Admin types
|
||||||
export interface WheelPrizeAdmin {
|
export interface WheelPrizeAdmin {
|
||||||
id: number
|
id: number;
|
||||||
config_id: number
|
config_id: number;
|
||||||
prize_type: string
|
prize_type: string;
|
||||||
prize_value: number
|
prize_value: number;
|
||||||
display_name: string
|
display_name: string;
|
||||||
emoji: string
|
emoji: string;
|
||||||
color: string
|
color: string;
|
||||||
prize_value_kopeks: number
|
prize_value_kopeks: number;
|
||||||
sort_order: number
|
sort_order: number;
|
||||||
manual_probability: number | null
|
manual_probability: number | null;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
promo_balance_bonus_kopeks: number
|
promo_balance_bonus_kopeks: number;
|
||||||
promo_subscription_days: number
|
promo_subscription_days: number;
|
||||||
promo_traffic_gb: number
|
promo_traffic_gb: number;
|
||||||
created_at: string | null
|
created_at: string | null;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for creating a new prize (excludes id, config_id which are auto-generated)
|
// Type for creating a new prize (excludes id, config_id which are auto-generated)
|
||||||
export interface CreateWheelPrizeData {
|
export interface CreateWheelPrizeData {
|
||||||
prize_type: string
|
prize_type: string;
|
||||||
prize_value: number
|
prize_value: number;
|
||||||
display_name: string
|
display_name: string;
|
||||||
emoji?: string
|
emoji?: string;
|
||||||
color?: string
|
color?: string;
|
||||||
prize_value_kopeks: number
|
prize_value_kopeks: number;
|
||||||
sort_order?: number
|
sort_order?: number;
|
||||||
manual_probability?: number | null
|
manual_probability?: number | null;
|
||||||
is_active?: boolean
|
is_active?: boolean;
|
||||||
promo_balance_bonus_kopeks?: number
|
promo_balance_bonus_kopeks?: number;
|
||||||
promo_subscription_days?: number
|
promo_subscription_days?: number;
|
||||||
promo_traffic_gb?: number
|
promo_traffic_gb?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminWheelConfig {
|
export interface AdminWheelConfig {
|
||||||
id: number
|
id: number;
|
||||||
is_enabled: boolean
|
is_enabled: boolean;
|
||||||
name: string
|
name: string;
|
||||||
spin_cost_stars: number
|
spin_cost_stars: number;
|
||||||
spin_cost_days: number
|
spin_cost_days: number;
|
||||||
spin_cost_stars_enabled: boolean
|
spin_cost_stars_enabled: boolean;
|
||||||
spin_cost_days_enabled: boolean
|
spin_cost_days_enabled: boolean;
|
||||||
rtp_percent: number
|
rtp_percent: number;
|
||||||
daily_spin_limit: number
|
daily_spin_limit: number;
|
||||||
min_subscription_days_for_day_payment: number
|
min_subscription_days_for_day_payment: number;
|
||||||
promo_prefix: string
|
promo_prefix: string;
|
||||||
promo_validity_days: number
|
promo_validity_days: number;
|
||||||
prizes: WheelPrizeAdmin[]
|
prizes: WheelPrizeAdmin[];
|
||||||
created_at: string | null
|
created_at: string | null;
|
||||||
updated_at: string | null
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WheelStatistics {
|
export interface WheelStatistics {
|
||||||
total_spins: number
|
total_spins: number;
|
||||||
total_revenue_kopeks: number
|
total_revenue_kopeks: number;
|
||||||
total_payout_kopeks: number
|
total_payout_kopeks: number;
|
||||||
actual_rtp_percent: number
|
actual_rtp_percent: number;
|
||||||
configured_rtp_percent: number
|
configured_rtp_percent: number;
|
||||||
spins_by_payment_type: Record<string, { count: number; total_kopeks: number }>
|
spins_by_payment_type: Record<string, { count: number; total_kopeks: number }>;
|
||||||
prizes_distribution: Array<{
|
prizes_distribution: Array<{
|
||||||
prize_type: string
|
prize_type: string;
|
||||||
display_name: string
|
display_name: string;
|
||||||
count: number
|
count: number;
|
||||||
total_kopeks: number
|
total_kopeks: number;
|
||||||
}>
|
}>;
|
||||||
top_wins: Array<{
|
top_wins: Array<{
|
||||||
user_id: number
|
user_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
prize_display_name: string
|
prize_display_name: string;
|
||||||
prize_value_kopeks: number
|
prize_value_kopeks: number;
|
||||||
created_at: string | null
|
created_at: string | null;
|
||||||
}>
|
}>;
|
||||||
period_from: string | null
|
period_from: string | null;
|
||||||
period_to: string | null
|
period_to: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSpinItem {
|
export interface AdminSpinItem {
|
||||||
id: number
|
id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
username: string | null
|
username: string | null;
|
||||||
payment_type: string
|
payment_type: string;
|
||||||
payment_amount: number
|
payment_amount: number;
|
||||||
payment_value_kopeks: number
|
payment_value_kopeks: number;
|
||||||
prize_type: string
|
prize_type: string;
|
||||||
prize_value: number
|
prize_value: number;
|
||||||
prize_display_name: string
|
prize_display_name: string;
|
||||||
prize_value_kopeks: number
|
prize_value_kopeks: number;
|
||||||
is_applied: boolean
|
is_applied: boolean;
|
||||||
created_at: string
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSpinsResponse {
|
export interface AdminSpinsResponse {
|
||||||
items: AdminSpinItem[]
|
items: AdminSpinItem[];
|
||||||
total: number
|
total: number;
|
||||||
page: number
|
page: number;
|
||||||
per_page: number
|
per_page: number;
|
||||||
pages: number
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== USER API ====================
|
// ==================== USER API ====================
|
||||||
@@ -184,101 +184,107 @@ export interface AdminSpinsResponse {
|
|||||||
export const wheelApi = {
|
export const wheelApi = {
|
||||||
// Get wheel config
|
// Get wheel config
|
||||||
getConfig: async (): Promise<WheelConfig> => {
|
getConfig: async (): Promise<WheelConfig> => {
|
||||||
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config')
|
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Check spin availability
|
// Check spin availability
|
||||||
checkAvailability: async (): Promise<SpinAvailability> => {
|
checkAvailability: async (): Promise<SpinAvailability> => {
|
||||||
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability')
|
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Spin the wheel
|
// Spin the wheel
|
||||||
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
|
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
|
||||||
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
|
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
|
||||||
payment_type: paymentType,
|
payment_type: paymentType,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get spin history
|
// Get spin history
|
||||||
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
|
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
|
||||||
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
|
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
|
||||||
params: { page, per_page: perPage },
|
params: { page, per_page: perPage },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create Stars invoice for Mini App payment
|
// Create Stars invoice for Mini App payment
|
||||||
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
|
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
|
||||||
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice')
|
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// ==================== ADMIN API ====================
|
// ==================== ADMIN API ====================
|
||||||
|
|
||||||
export const adminWheelApi = {
|
export const adminWheelApi = {
|
||||||
// Get full config
|
// Get full config
|
||||||
getConfig: async (): Promise<AdminWheelConfig> => {
|
getConfig: async (): Promise<AdminWheelConfig> => {
|
||||||
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config')
|
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update config
|
// Update config
|
||||||
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
|
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
|
||||||
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data)
|
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get prizes
|
// Get prizes
|
||||||
getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
|
getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
|
||||||
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes')
|
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes');
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create prize
|
// Create prize
|
||||||
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
|
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
|
||||||
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data)
|
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data);
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update prize
|
// Update prize
|
||||||
updatePrize: async (prizeId: number, data: Partial<WheelPrizeAdmin>): Promise<WheelPrizeAdmin> => {
|
updatePrize: async (
|
||||||
const response = await apiClient.put<WheelPrizeAdmin>(`/cabinet/admin/wheel/prizes/${prizeId}`, data)
|
prizeId: number,
|
||||||
return response.data
|
data: Partial<WheelPrizeAdmin>,
|
||||||
|
): Promise<WheelPrizeAdmin> => {
|
||||||
|
const response = await apiClient.put<WheelPrizeAdmin>(
|
||||||
|
`/cabinet/admin/wheel/prizes/${prizeId}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete prize
|
// Delete prize
|
||||||
deletePrize: async (prizeId: number): Promise<void> => {
|
deletePrize: async (prizeId: number): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`)
|
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reorder prizes
|
// Reorder prizes
|
||||||
reorderPrizes: async (prizeIds: number[]): Promise<void> => {
|
reorderPrizes: async (prizeIds: number[]): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds })
|
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get statistics
|
// Get statistics
|
||||||
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
|
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
|
||||||
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
|
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
|
||||||
params: { date_from: dateFrom, date_to: dateTo },
|
params: { date_from: dateFrom, date_to: dateTo },
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all spins
|
// Get all spins
|
||||||
getSpins: async (params?: {
|
getSpins: async (params?: {
|
||||||
user_id?: number
|
user_id?: number;
|
||||||
date_from?: string
|
date_from?: string;
|
||||||
date_to?: string
|
date_to?: string;
|
||||||
page?: number
|
page?: number;
|
||||||
per_page?: number
|
per_page?: number;
|
||||||
}): Promise<AdminSpinsResponse> => {
|
}): Promise<AdminSpinsResponse> => {
|
||||||
const response = await apiClient.get<AdminSpinsResponse>('/cabinet/admin/wheel/spins', {
|
const response = await apiClient.get<AdminSpinsResponse>('/cabinet/admin/wheel/spins', {
|
||||||
params,
|
params,
|
||||||
})
|
});
|
||||||
return response.data
|
return response.data;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,43 +1,44 @@
|
|||||||
import { useEffect, useState, memo } from 'react'
|
import { useEffect, useState, memo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { brandingApi } from '../api/branding'
|
import { brandingApi } from '../api/branding';
|
||||||
|
|
||||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
|
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled';
|
||||||
|
|
||||||
// Detect if user prefers reduced motion
|
// Detect if user prefers reduced motion
|
||||||
const isLowPerformance = (): boolean => {
|
const isLowPerformance = (): boolean => {
|
||||||
// Only check for reduced motion preference - let animation run everywhere else
|
// Only check for reduced motion preference - let animation run everywhere else
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
return prefersReducedMotion
|
return prefersReducedMotion;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get cached value from localStorage
|
// Get cached value from localStorage
|
||||||
const getCachedAnimationEnabled = (): boolean | null => {
|
const getCachedAnimationEnabled = (): boolean | null => {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY)
|
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||||
if (cached !== null) {
|
if (cached !== null) {
|
||||||
return cached === 'true'
|
return cached === 'true';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage not available
|
// localStorage not available
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Update cache in localStorage
|
// Update cache in localStorage
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const setCachedAnimationEnabled = (enabled: boolean) => {
|
export const setCachedAnimationEnabled = (enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled))
|
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled));
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage not available
|
// localStorage not available
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Memoized background component to prevent re-renders
|
// Memoized background component to prevent re-renders
|
||||||
const AnimatedBackground = memo(function AnimatedBackground() {
|
const AnimatedBackground = memo(function AnimatedBackground() {
|
||||||
// Start with cached value (null means unknown yet)
|
// Start with cached value (null means unknown yet)
|
||||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
|
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled());
|
||||||
const [isLowPerf] = useState(() => isLowPerformance())
|
const [isLowPerf] = useState(() => isLowPerformance());
|
||||||
|
|
||||||
const { data: animationSettings } = useQuery({
|
const { data: animationSettings } = useQuery({
|
||||||
queryKey: ['animation-enabled'],
|
queryKey: ['animation-enabled'],
|
||||||
@@ -45,24 +46,24 @@ const AnimatedBackground = memo(function AnimatedBackground() {
|
|||||||
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
||||||
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Update state and cache when data arrives
|
// Update state and cache when data arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (animationSettings !== undefined) {
|
if (animationSettings !== undefined) {
|
||||||
const enabled = animationSettings.enabled
|
const enabled = animationSettings.enabled;
|
||||||
setIsEnabled(enabled)
|
setIsEnabled(enabled);
|
||||||
setCachedAnimationEnabled(enabled)
|
setCachedAnimationEnabled(enabled);
|
||||||
}
|
}
|
||||||
}, [animationSettings])
|
}, [animationSettings]);
|
||||||
|
|
||||||
// Don't render if disabled or on low-performance devices
|
// Don't render if disabled or on low-performance devices
|
||||||
if (isEnabled !== true || isLowPerf) {
|
if (isEnabled !== true || isLowPerf) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render only 2 blobs on mobile for better performance
|
// Render only 2 blobs on mobile for better performance
|
||||||
const isMobile = window.innerWidth < 768
|
const isMobile = window.innerWidth < 768;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="wave-bg-container">
|
<div className="wave-bg-container">
|
||||||
@@ -75,7 +76,7 @@ const AnimatedBackground = memo(function AnimatedBackground() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
export default AnimatedBackground
|
export default AnimatedBackground;
|
||||||
|
|||||||
@@ -1,238 +1,286 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
value: string
|
value: string;
|
||||||
onChange: (color: string) => void
|
onChange: (color: string) => void;
|
||||||
label: string
|
label: string;
|
||||||
description?: string
|
description?: string;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if running in Telegram WebApp
|
// Check if running in Telegram WebApp
|
||||||
const isTelegramWebApp = (): boolean => {
|
const isTelegramWebApp = (): boolean => {
|
||||||
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Convert hex to RGB
|
// Convert hex to RGB
|
||||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
return result
|
return result
|
||||||
? {
|
? {
|
||||||
r: parseInt(result[1], 16),
|
r: parseInt(result[1], 16),
|
||||||
g: parseInt(result[2], 16),
|
g: parseInt(result[2], 16),
|
||||||
b: parseInt(result[3], 16),
|
b: parseInt(result[3], 16),
|
||||||
}
|
}
|
||||||
: { r: 0, g: 0, b: 0 }
|
: { r: 0, g: 0, b: 0 };
|
||||||
}
|
};
|
||||||
|
|
||||||
// Convert RGB to hex
|
// Convert RGB to hex
|
||||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||||
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
|
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
};
|
||||||
|
|
||||||
// Convert RGB to HSL
|
// Convert RGB to HSL
|
||||||
const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => {
|
const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => {
|
||||||
r /= 255; g /= 255; b /= 255
|
r /= 255;
|
||||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
g /= 255;
|
||||||
let h = 0, s = 0
|
b /= 255;
|
||||||
const l = (max + min) / 2
|
const max = Math.max(r, g, b),
|
||||||
|
min = Math.min(r, g, b);
|
||||||
|
let h = 0,
|
||||||
|
s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
if (max !== min) {
|
if (max !== min) {
|
||||||
const d = max - min
|
const d = max - min;
|
||||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
switch (max) {
|
switch (max) {
|
||||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
|
case r:
|
||||||
case g: h = ((b - r) / d + 2) / 6; break
|
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
case b: h = ((r - g) / d + 4) / 6; break
|
break;
|
||||||
|
case g:
|
||||||
|
h = ((b - r) / d + 2) / 6;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = ((r - g) / d + 4) / 6;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
|
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||||
}
|
};
|
||||||
|
|
||||||
// Convert HSL to RGB
|
// Convert HSL to RGB
|
||||||
const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => {
|
const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => {
|
||||||
h /= 360; s /= 100; l /= 100
|
h /= 360;
|
||||||
let r, g, b
|
s /= 100;
|
||||||
|
l /= 100;
|
||||||
|
let r, g, b;
|
||||||
if (s === 0) {
|
if (s === 0) {
|
||||||
r = g = b = l
|
r = g = b = l;
|
||||||
} else {
|
} else {
|
||||||
const hue2rgb = (p: number, q: number, t: number) => {
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
if (t < 0) t += 1
|
if (t < 0) t += 1;
|
||||||
if (t > 1) t -= 1
|
if (t > 1) t -= 1;
|
||||||
if (t < 1/6) return p + (q - p) * 6 * t
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||||
if (t < 1/2) return q
|
if (t < 1 / 2) return q;
|
||||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||||
return p
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1 / 3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1 / 3);
|
||||||
}
|
}
|
||||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
||||||
const p = 2 * l - q
|
};
|
||||||
r = hue2rgb(p, q, h + 1/3)
|
|
||||||
g = hue2rgb(p, q, h)
|
|
||||||
b = hue2rgb(p, q, h - 1/3)
|
|
||||||
}
|
|
||||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
|
'#3b82f6',
|
||||||
'#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6',
|
'#ef4444',
|
||||||
'#84cc16', '#f97316', '#6366f1', '#a855f7',
|
'#22c55e',
|
||||||
]
|
'#f59e0b',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#ec4899',
|
||||||
|
'#06b6d4',
|
||||||
|
'#14b8a6',
|
||||||
|
'#84cc16',
|
||||||
|
'#f97316',
|
||||||
|
'#6366f1',
|
||||||
|
'#a855f7',
|
||||||
|
];
|
||||||
|
|
||||||
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
|
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [localValue, setLocalValue] = useState(value)
|
const [localValue, setLocalValue] = useState(value);
|
||||||
const [hsl, setHsl] = useState(() => {
|
const [hsl, setHsl] = useState(() => {
|
||||||
const rgb = hexToRgb(value)
|
const rgb = hexToRgb(value);
|
||||||
return rgbToHsl(rgb.r, rgb.g, rgb.b)
|
return rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||||
})
|
});
|
||||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false })
|
const [pickerPosition, setPickerPosition] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
openUp: boolean;
|
||||||
|
}>({ top: 0, left: 0, openUp: false });
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const pickerRef = useRef<HTMLDivElement>(null)
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const isTelegram = useMemo(() => isTelegramWebApp(), [])
|
const isTelegram = useMemo(() => isTelegramWebApp(), []);
|
||||||
|
|
||||||
// Sync with external value
|
// Sync with external value
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalValue(value)
|
setLocalValue(value);
|
||||||
const rgb = hexToRgb(value)
|
const rgb = hexToRgb(value);
|
||||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||||
}, [value])
|
}, [value]);
|
||||||
|
|
||||||
// Calculate picker position
|
// Calculate picker position
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (!buttonRef.current) return
|
if (!buttonRef.current) return;
|
||||||
|
|
||||||
const rect = buttonRef.current.getBoundingClientRect()
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
const pickerHeight = 320
|
const pickerHeight = 320;
|
||||||
const pickerWidth = 280
|
const pickerWidth = 280;
|
||||||
const padding = 12
|
const padding = 12;
|
||||||
|
|
||||||
// Check if there's space below
|
// Check if there's space below
|
||||||
const spaceBelow = window.innerHeight - rect.bottom
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
const spaceAbove = rect.top
|
const spaceAbove = rect.top;
|
||||||
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow
|
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow;
|
||||||
|
|
||||||
// Calculate left position (ensure it stays in viewport)
|
// Calculate left position (ensure it stays in viewport)
|
||||||
let left = rect.left
|
let left = rect.left;
|
||||||
if (left + pickerWidth > window.innerWidth - padding) {
|
if (left + pickerWidth > window.innerWidth - padding) {
|
||||||
left = window.innerWidth - pickerWidth - padding
|
left = window.innerWidth - pickerWidth - padding;
|
||||||
}
|
}
|
||||||
if (left < padding) left = padding
|
if (left < padding) left = padding;
|
||||||
|
|
||||||
setPickerPosition({
|
setPickerPosition({
|
||||||
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
|
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
|
||||||
left,
|
left,
|
||||||
openUp
|
openUp,
|
||||||
})
|
});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Open picker
|
// Open picker
|
||||||
const handleOpen = useCallback(() => {
|
const handleOpen = useCallback(() => {
|
||||||
if (disabled) return
|
if (disabled) return;
|
||||||
updatePosition()
|
updatePosition();
|
||||||
setIsOpen(true)
|
setIsOpen(true);
|
||||||
}, [disabled, updatePosition])
|
}, [disabled, updatePosition]);
|
||||||
|
|
||||||
// Close picker
|
// Close picker
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Handle click outside
|
// Handle click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return;
|
||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
pickerRef.current && !pickerRef.current.contains(e.target as Node) &&
|
pickerRef.current &&
|
||||||
buttonRef.current && !buttonRef.current.contains(e.target as Node)
|
!pickerRef.current.contains(e.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(e.target as Node)
|
||||||
) {
|
) {
|
||||||
handleClose()
|
handleClose();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleScroll = () => handleClose()
|
const handleScroll = () => handleClose();
|
||||||
const handleResize = () => updatePosition()
|
const handleResize = () => updatePosition();
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
document.addEventListener('touchstart', handleClickOutside as EventListener)
|
document.addEventListener('touchstart', handleClickOutside as EventListener);
|
||||||
window.addEventListener('scroll', handleScroll, true)
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
document.removeEventListener('touchstart', handleClickOutside as EventListener)
|
document.removeEventListener('touchstart', handleClickOutside as EventListener);
|
||||||
window.removeEventListener('scroll', handleScroll, true)
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize);
|
||||||
}
|
};
|
||||||
}, [isOpen, handleClose, updatePosition])
|
}, [isOpen, handleClose, updatePosition]);
|
||||||
|
|
||||||
// Update color from HSL
|
// Update color from HSL
|
||||||
const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => {
|
const updateColorFromHsl = useCallback(
|
||||||
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l)
|
(newHsl: { h: number; s: number; l: number }) => {
|
||||||
const hex = rgbToHex(rgb.r, rgb.g, rgb.b)
|
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
|
||||||
setHsl(newHsl)
|
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||||
setLocalValue(hex)
|
setHsl(newHsl);
|
||||||
onChange(hex)
|
setLocalValue(hex);
|
||||||
}, [onChange])
|
onChange(hex);
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle hue change
|
// Handle hue change
|
||||||
const handleHueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleHueChange = useCallback(
|
||||||
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) })
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
}, [hsl, updateColorFromHsl])
|
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) });
|
||||||
|
},
|
||||||
|
[hsl, updateColorFromHsl],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle saturation change
|
// Handle saturation change
|
||||||
const handleSaturationChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSaturationChange = useCallback(
|
||||||
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) })
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
}, [hsl, updateColorFromHsl])
|
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) });
|
||||||
|
},
|
||||||
|
[hsl, updateColorFromHsl],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle lightness change
|
// Handle lightness change
|
||||||
const handleLightnessChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLightnessChange = useCallback(
|
||||||
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) })
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
}, [hsl, updateColorFromHsl])
|
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) });
|
||||||
|
},
|
||||||
|
[hsl, updateColorFromHsl],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle native color input
|
// Handle native color input
|
||||||
const handleColorInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleColorInputChange = useCallback(
|
||||||
const newColor = e.target.value
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setLocalValue(newColor)
|
const newColor = e.target.value;
|
||||||
const rgb = hexToRgb(newColor)
|
setLocalValue(newColor);
|
||||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
const rgb = hexToRgb(newColor);
|
||||||
onChange(newColor)
|
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||||
}, [onChange])
|
onChange(newColor);
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle hex input
|
// Handle hex input
|
||||||
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleHexInputChange = useCallback(
|
||||||
let newValue = e.target.value
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let newValue = e.target.value;
|
||||||
if (newValue && !newValue.startsWith('#')) {
|
if (newValue && !newValue.startsWith('#')) {
|
||||||
newValue = '#' + newValue
|
newValue = '#' + newValue;
|
||||||
}
|
}
|
||||||
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
|
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
|
||||||
setLocalValue(newValue)
|
setLocalValue(newValue);
|
||||||
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
|
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
|
||||||
const rgb = hexToRgb(newValue)
|
const rgb = hexToRgb(newValue);
|
||||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||||
onChange(newValue)
|
onChange(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onChange])
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle preset click
|
// Handle preset click
|
||||||
const handlePresetClick = useCallback((color: string) => {
|
const handlePresetClick = useCallback(
|
||||||
setLocalValue(color)
|
(color: string) => {
|
||||||
const rgb = hexToRgb(color)
|
setLocalValue(color);
|
||||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
const rgb = hexToRgb(color);
|
||||||
onChange(color)
|
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||||
handleClose()
|
onChange(color);
|
||||||
}, [onChange, handleClose])
|
handleClose();
|
||||||
|
},
|
||||||
|
[onChange, handleClose],
|
||||||
|
);
|
||||||
|
|
||||||
// Picker content
|
// Picker content
|
||||||
const pickerContent = isOpen ? (
|
const pickerContent = isOpen ? (
|
||||||
<div
|
<div
|
||||||
ref={pickerRef}
|
ref={pickerRef}
|
||||||
className="fixed z-[9999] w-[280px] bg-dark-900 rounded-2xl border border-dark-700 shadow-2xl overflow-hidden"
|
className="fixed z-[9999] w-[280px] overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
|
||||||
style={{
|
style={{
|
||||||
top: pickerPosition.top,
|
top: pickerPosition.top,
|
||||||
left: pickerPosition.left,
|
left: pickerPosition.left,
|
||||||
@@ -240,13 +288,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Color preview header */}
|
{/* Color preview header */}
|
||||||
<div
|
<div className="h-16 w-full" style={{ backgroundColor: localValue || '#000000' }} />
|
||||||
className="h-16 w-full"
|
|
||||||
style={{ backgroundColor: localValue || '#000000' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="p-4 space-y-4">
|
<div className="space-y-4 p-4">
|
||||||
{/* Hue slider */}
|
{/* Hue slider */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -259,9 +304,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
max="360"
|
max="360"
|
||||||
value={hsl.h}
|
value={hsl.h}
|
||||||
onChange={handleHueChange}
|
onChange={handleHueChange}
|
||||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
|
background:
|
||||||
|
'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +324,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
max="100"
|
max="100"
|
||||||
value={hsl.s}
|
value={hsl.s}
|
||||||
onChange={handleSaturationChange}
|
onChange={handleSaturationChange}
|
||||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
|
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
|
||||||
}}
|
}}
|
||||||
@@ -297,7 +343,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
max="100"
|
max="100"
|
||||||
value={hsl.l}
|
value={hsl.l}
|
||||||
onChange={handleLightnessChange}
|
onChange={handleLightnessChange}
|
||||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,
|
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,
|
||||||
}}
|
}}
|
||||||
@@ -311,21 +357,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
type="text"
|
type="text"
|
||||||
value={localValue}
|
value={localValue}
|
||||||
onChange={handleHexInputChange}
|
onChange={handleHexInputChange}
|
||||||
className="flex-1 h-9 px-3 text-sm font-mono uppercase bg-dark-800 border border-dark-700 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="h-9 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Presets */}
|
{/* Presets */}
|
||||||
<div className="pt-2 border-t border-dark-700">
|
<div className="border-t border-dark-700 pt-2">
|
||||||
<span className="text-xs font-medium text-dark-400 block mb-2">Presets</span>
|
<span className="mb-2 block text-xs font-medium text-dark-400">Presets</span>
|
||||||
<div className="grid grid-cols-6 gap-1.5">
|
<div className="grid grid-cols-6 gap-1.5">
|
||||||
{PRESET_COLORS.map((preset) => (
|
{PRESET_COLORS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
onClick={() => handlePresetClick(preset)}
|
onClick={() => handlePresetClick(preset)}
|
||||||
className={`w-full aspect-square rounded-lg transition-transform hover:scale-110 active:scale-95 ${
|
className={`aspect-square w-full rounded-lg transition-transform hover:scale-110 active:scale-95 ${
|
||||||
localValue.toLowerCase() === preset.toLowerCase()
|
localValue.toLowerCase() === preset.toLowerCase()
|
||||||
? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900'
|
? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900'
|
||||||
: ''
|
: ''
|
||||||
@@ -338,12 +384,12 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-0 overflow-hidden">
|
<div className="relative min-w-0 overflow-hidden">
|
||||||
<label className="block text-sm font-medium text-dark-200 mb-1 truncate">{label}</label>
|
<label className="mb-1 block truncate text-sm font-medium text-dark-200">{label}</label>
|
||||||
{description && <p className="text-xs text-dark-500 mb-2 truncate">{description}</p>}
|
{description && <p className="mb-2 truncate text-xs text-dark-500">{description}</p>}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Color preview button */}
|
{/* Color preview button */}
|
||||||
@@ -352,7 +398,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-10 h-10 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
|
className="h-10 w-10 flex-shrink-0 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
style={{ backgroundColor: localValue || '#000000' }}
|
style={{ backgroundColor: localValue || '#000000' }}
|
||||||
title={localValue}
|
title={localValue}
|
||||||
/>
|
/>
|
||||||
@@ -363,7 +409,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
value={localValue}
|
value={localValue}
|
||||||
onChange={handleHexInputChange}
|
onChange={handleHexInputChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex-1 min-w-0 h-10 px-2 font-mono text-sm uppercase bg-dark-800 border border-dark-700 rounded-xl text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50"
|
className="h-10 min-w-0 flex-1 rounded-xl border border-dark-700 bg-dark-800 px-2 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50"
|
||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
/>
|
/>
|
||||||
@@ -383,11 +429,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => colorInputRef.current?.click()}
|
onClick={() => colorInputRef.current?.click()}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||||
title="System color picker"
|
title="System color picker"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -397,5 +453,5 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
{/* Render picker in portal */}
|
{/* Render picker in portal */}
|
||||||
{typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
|
{typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,465 +1,537 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { subscriptionApi } from '../api/subscription'
|
import { subscriptionApi } from '../api/subscription';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
import type { AppInfo, AppConfig, LocalizedText } from '../types'
|
import type { AppInfo, AppConfig, LocalizedText } from '../types';
|
||||||
|
|
||||||
interface ConnectionModalProps {
|
interface ConnectionModalProps {
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CopyIcon = () => (
|
const CopyIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const LinkIcon = () => (
|
const LinkIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChevronIcon = () => (
|
const ChevronIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// App icons
|
// App icons
|
||||||
const HappIcon = () => (
|
const HappIcon = () => (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
|
<svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
|
||||||
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z"/>
|
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z" />
|
||||||
<path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z"/>
|
<path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z" />
|
||||||
<path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z"/>
|
<path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z" />
|
||||||
<path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z"/>
|
<path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z" />
|
||||||
<path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z"/>
|
<path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z" />
|
||||||
<path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z"/>
|
<path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ClashMetaIcon = () => (
|
const ClashMetaIcon = () => (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
|
<svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
|
||||||
<path fillRule="evenodd" clipRule="evenodd" d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"/>
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ShadowrocketIcon = () => (
|
const ShadowrocketIcon = () => (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg
|
||||||
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z"/>
|
className="h-6 w-6"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const StreisandIcon = () => (
|
const StreisandIcon = () => (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor">
|
<svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
|
||||||
<path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591Z"/>
|
<path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591Z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const getAppIcon = (appName: string): React.ReactNode => {
|
const getAppIcon = (appName: string): React.ReactNode => {
|
||||||
const name = appName.toLowerCase()
|
const name = appName.toLowerCase();
|
||||||
if (name.includes('happ')) return <HappIcon />
|
if (name.includes('happ')) return <HappIcon />;
|
||||||
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />
|
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />;
|
||||||
if (name.includes('streisand')) return <StreisandIcon />
|
if (name.includes('streisand')) return <StreisandIcon />;
|
||||||
if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return <ClashMetaIcon />
|
if (name.includes('clash') || name.includes('meta') || name.includes('verge'))
|
||||||
return <span className="text-lg">📦</span>
|
return <ClashMetaIcon />;
|
||||||
}
|
return <span className="text-lg">📦</span>;
|
||||||
|
};
|
||||||
|
|
||||||
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
|
||||||
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
// eslint-disable-next-line no-script-url -- listing dangerous schemes to block them
|
||||||
|
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
|
||||||
|
|
||||||
function isValidExternalUrl(url: string | undefined): boolean {
|
function isValidExternalUrl(url: string | undefined): boolean {
|
||||||
if (!url) return false
|
if (!url) return false;
|
||||||
const lowerUrl = url.toLowerCase().trim()
|
const lowerUrl = url.toLowerCase().trim();
|
||||||
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
|
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
|
||||||
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
|
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidDeepLink(url: string | undefined): boolean {
|
function isValidDeepLink(url: string | undefined): boolean {
|
||||||
if (!url) return false
|
if (!url) return false;
|
||||||
const lowerUrl = url.toLowerCase().trim()
|
const lowerUrl = url.toLowerCase().trim();
|
||||||
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
|
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
|
||||||
return lowerUrl.includes('://')
|
return lowerUrl.includes('://');
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectPlatform(): string | null {
|
function detectPlatform(): string | null {
|
||||||
if (typeof window === 'undefined' || !navigator?.userAgent) return null
|
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
|
||||||
const ua = navigator.userAgent.toLowerCase()
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
if (/iphone|ipad|ipod/.test(ua)) return 'ios'
|
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
|
||||||
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'
|
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android';
|
||||||
if (/macintosh|mac os x/.test(ua)) return 'macos'
|
if (/macintosh|mac os x/.test(ua)) return 'macos';
|
||||||
if (/windows/.test(ua)) return 'windows'
|
if (/windows/.test(ua)) return 'windows';
|
||||||
if (/linux/.test(ua)) return 'linux'
|
if (/linux/.test(ua)) return 'linux';
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIsMobile() {
|
function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false;
|
||||||
return window.innerWidth < 768
|
return window.innerWidth < 768;
|
||||||
})
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setIsMobile(window.innerWidth < 768)
|
const check = () => setIsMobile(window.innerWidth < 768);
|
||||||
window.addEventListener('resize', check)
|
window.addEventListener('resize', check);
|
||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check);
|
||||||
}, [])
|
}, []);
|
||||||
return isMobile
|
return isMobile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation();
|
||||||
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
|
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false);
|
||||||
const [showAppSelector, setShowAppSelector] = useState(false)
|
const [showAppSelector, setShowAppSelector] = useState(false);
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null)
|
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||||
|
|
||||||
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
|
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } =
|
||||||
const isMobileScreen = useIsMobile()
|
useTelegramWebApp();
|
||||||
const isMobile = isMobileScreen
|
const isMobileScreen = useIsMobile();
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const isMobile = isMobileScreen;
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Ref для хранения актуального обработчика BackButton (фикс мигания)
|
// Ref для хранения актуального обработчика BackButton (фикс мигания)
|
||||||
const backButtonHandlerRef = useRef<() => void>(() => {})
|
const backButtonHandlerRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
// Prevent scroll events from bubbling to parent/Telegram
|
// Prevent scroll events from bubbling to parent/Telegram
|
||||||
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
|
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
|
||||||
const container = e.currentTarget
|
const container = e.currentTarget;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
const isAtTop = scrollTop === 0
|
const isAtTop = scrollTop === 0;
|
||||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
|
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
|
||||||
|
|
||||||
// Prevent scroll propagation when not at boundaries, or when scrolling away from boundary
|
// Prevent scroll propagation when not at boundaries, or when scrolling away from boundary
|
||||||
if ((!isAtTop && !isAtBottom) ||
|
if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
|
||||||
(isAtTop && e.deltaY > 0) ||
|
e.stopPropagation();
|
||||||
(isAtBottom && e.deltaY < 0)) {
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
|
const safeBottom = isTelegramWebApp
|
||||||
const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0
|
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
||||||
|
: 0;
|
||||||
|
const safeTop = isTelegramWebApp
|
||||||
|
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const { data: appConfig, isLoading, error } = useQuery<AppConfig>({
|
const {
|
||||||
|
data: appConfig,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery<AppConfig>({
|
||||||
queryKey: ['appConfig'],
|
queryKey: ['appConfig'],
|
||||||
queryFn: () => subscriptionApi.getAppConfig(),
|
queryFn: () => subscriptionApi.getAppConfig(),
|
||||||
})
|
});
|
||||||
|
|
||||||
const detectedPlatform = useMemo(() => detectPlatform(), [])
|
const detectedPlatform = useMemo(() => detectPlatform(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appConfig?.platforms || selectedApp) return
|
if (!appConfig?.platforms || selectedApp) return;
|
||||||
let platform = detectedPlatform
|
let platform = detectedPlatform;
|
||||||
if (!platform || !appConfig.platforms[platform]?.length) {
|
if (!platform || !appConfig.platforms[platform]?.length) {
|
||||||
platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null
|
platform = platformOrder.find((p) => appConfig.platforms[p]?.length > 0) || null;
|
||||||
}
|
}
|
||||||
if (!platform || !appConfig.platforms[platform]?.length) return
|
if (!platform || !appConfig.platforms[platform]?.length) return;
|
||||||
const apps = appConfig.platforms[platform]
|
const apps = appConfig.platforms[platform];
|
||||||
const app = apps.find(a => a.isFeatured) || apps[0]
|
const app = apps.find((a) => a.isFeatured) || apps[0];
|
||||||
if (app) setSelectedApp(app)
|
if (app) setSelectedApp(app);
|
||||||
}, [appConfig, detectedPlatform, selectedApp])
|
}, [appConfig, detectedPlatform, selectedApp]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onClose()
|
onClose();
|
||||||
}, [onClose])
|
}, [onClose]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (selectedPlatform) {
|
if (selectedPlatform) {
|
||||||
setSelectedPlatform(null)
|
setSelectedPlatform(null);
|
||||||
} else {
|
} else {
|
||||||
setShowAppSelector(false)
|
setShowAppSelector(false);
|
||||||
}
|
}
|
||||||
}, [selectedPlatform])
|
}, [selectedPlatform]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
if (showAppSelector) handleBack()
|
if (showAppSelector) handleBack();
|
||||||
else handleClose()
|
else handleClose();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleClose, handleBack, showAppSelector])
|
}, [handleClose, handleBack, showAppSelector]);
|
||||||
|
|
||||||
// Обновляем ref при изменении логики (без перезапуска эффекта BackButton)
|
// Обновляем ref при изменении логики (без перезапуска эффекта BackButton)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose
|
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
|
||||||
}, [showAppSelector, handleBack, handleClose])
|
}, [showAppSelector, handleBack, handleClose]);
|
||||||
|
|
||||||
// Управление BackButton — эффект запускается только при mount/unmount
|
// Управление BackButton — эффект запускается только при mount/unmount
|
||||||
// Используем стабильный обработчик через ref, чтобы избежать мигания
|
// Используем стабильный обработчик через ref, чтобы избежать мигания
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webApp?.BackButton) return
|
if (!webApp?.BackButton) return;
|
||||||
|
|
||||||
const stableHandler = () => {
|
const stableHandler = () => {
|
||||||
backButtonHandlerRef.current()
|
backButtonHandlerRef.current();
|
||||||
}
|
};
|
||||||
|
|
||||||
webApp.BackButton.show()
|
webApp.BackButton.show();
|
||||||
webApp.BackButton.onClick(stableHandler)
|
webApp.BackButton.onClick(stableHandler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
webApp.BackButton.offClick(stableHandler)
|
webApp.BackButton.offClick(stableHandler);
|
||||||
webApp.BackButton.hide()
|
webApp.BackButton.hide();
|
||||||
}
|
};
|
||||||
}, [webApp]) // Только webApp в зависимостях!
|
}, [webApp]); // Только webApp в зависимостях!
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden';
|
||||||
return () => { document.body.style.overflow = '' }
|
return () => {
|
||||||
}, [])
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getLocalizedText = (text: LocalizedText | undefined): string => {
|
const getLocalizedText = (text: LocalizedText | undefined): string => {
|
||||||
if (!text) return ''
|
if (!text) return '';
|
||||||
const lang = i18n.language || 'en'
|
const lang = i18n.language || 'en';
|
||||||
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''
|
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '';
|
||||||
}
|
};
|
||||||
|
|
||||||
const availablePlatforms = useMemo(() => {
|
const availablePlatforms = useMemo(() => {
|
||||||
if (!appConfig?.platforms) return []
|
if (!appConfig?.platforms) return [];
|
||||||
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
|
const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0);
|
||||||
if (detectedPlatform && available.includes(detectedPlatform)) {
|
if (detectedPlatform && available.includes(detectedPlatform)) {
|
||||||
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
|
return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)];
|
||||||
}
|
}
|
||||||
return available
|
return available;
|
||||||
}, [appConfig, detectedPlatform])
|
}, [appConfig, detectedPlatform]);
|
||||||
|
|
||||||
const copySubscriptionLink = async () => {
|
const copySubscriptionLink = async () => {
|
||||||
if (!appConfig?.subscriptionUrl) return
|
if (!appConfig?.subscriptionUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(appConfig.subscriptionUrl)
|
await navigator.clipboard.writeText(appConfig.subscriptionUrl);
|
||||||
setCopied(true)
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
const textarea = document.createElement('textarea')
|
const textarea = document.createElement('textarea');
|
||||||
textarea.value = appConfig.subscriptionUrl
|
textarea.value = appConfig.subscriptionUrl;
|
||||||
document.body.appendChild(textarea)
|
document.body.appendChild(textarea);
|
||||||
textarea.select()
|
textarea.select();
|
||||||
document.execCommand('copy')
|
document.execCommand('copy');
|
||||||
document.body.removeChild(textarea)
|
document.body.removeChild(textarea);
|
||||||
setCopied(true)
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConnect = (app: AppInfo) => {
|
const handleConnect = (app: AppInfo) => {
|
||||||
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return
|
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return;
|
||||||
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
|
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en';
|
||||||
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`
|
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`;
|
||||||
const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp
|
const tg = (
|
||||||
|
window as unknown as {
|
||||||
|
Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } };
|
||||||
|
}
|
||||||
|
).Telegram?.WebApp;
|
||||||
if (tg?.openLink) {
|
if (tg?.openLink) {
|
||||||
try {
|
try {
|
||||||
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
|
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true });
|
||||||
return
|
return;
|
||||||
} catch { /* fallback */ }
|
} catch {
|
||||||
|
/* fallback */
|
||||||
}
|
}
|
||||||
window.location.href = redirectUrl
|
|
||||||
}
|
}
|
||||||
|
window.location.href = redirectUrl;
|
||||||
|
};
|
||||||
|
|
||||||
// Wrapper component
|
// Wrapper component
|
||||||
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
const Wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col"
|
className="fixed inset-0 z-[9999] flex flex-col bg-dark-900"
|
||||||
style={{
|
style={{
|
||||||
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
|
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
|
||||||
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)'
|
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
if (typeof document !== 'undefined') return createPortal(content, document.body)
|
if (typeof document !== 'undefined') return createPortal(content, document.body);
|
||||||
return content
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desktop centered - positioned higher
|
// Desktop centered - positioned higher
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]" onClick={handleClose}>
|
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-md max-h-[85vh] bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl flex flex-col overflow-hidden"
|
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Loading
|
// Loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<div className="w-10 h-10 border-[3px] border-accent-500/30 border-t-accent-500 rounded-full animate-spin" />
|
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error
|
// Error
|
||||||
if (error || !appConfig) {
|
if (error || !appConfig) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||||
<p className="text-dark-300 text-lg mb-4">{t('common.error')}</p>
|
<p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
|
||||||
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
|
<button onClick={handleClose} className="btn-primary px-6 py-2">
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No subscription
|
// No subscription
|
||||||
if (!appConfig.hasSubscription) {
|
if (!appConfig.hasSubscription) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
|
||||||
<h3 className="font-bold text-dark-100 text-xl mb-2">{t('subscription.connection.title')}</h3>
|
<h3 className="mb-2 text-xl font-bold text-dark-100">
|
||||||
<p className="text-dark-400 mb-4">{t('subscription.connection.noSubscription')}</p>
|
{t('subscription.connection.title')}
|
||||||
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
|
</h3>
|
||||||
|
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
|
||||||
|
<button onClick={handleClose} className="btn-primary px-6 py-2">
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// App selector
|
// App selector
|
||||||
if (showAppSelector) {
|
if (showAppSelector) {
|
||||||
const platformNames: Record<string, string> = {
|
const platformNames: Record<string, string> = {
|
||||||
ios: 'iOS', android: 'Android', windows: 'Windows',
|
ios: 'iOS',
|
||||||
macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV'
|
android: 'Android',
|
||||||
}
|
windows: 'Windows',
|
||||||
|
macos: 'macOS',
|
||||||
|
linux: 'Linux',
|
||||||
|
androidTV: 'Android TV',
|
||||||
|
appleTV: 'Apple TV',
|
||||||
|
};
|
||||||
|
|
||||||
const platformIcons: Record<string, React.ReactNode> = {
|
const platformIcons: Record<string, React.ReactNode> = {
|
||||||
ios: (
|
ios: (
|
||||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
android: (
|
android: (
|
||||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.39-.59-2.94-.92-4.58-.92s-3.19.33-4.58.92L5.5 5.67c-.16-.28-.54-.37-.83-.22-.31.16-.43.54-.26.85l1.84 3.18C3.38 11.11 1.5 14.12 1.5 17.5h21c0-3.38-1.88-6.39-4.9-8.02zM7 15.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z"/>
|
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.39-.59-2.94-.92-4.58-.92s-3.19.33-4.58.92L5.5 5.67c-.16-.28-.54-.37-.83-.22-.31.16-.43.54-.26.85l1.84 3.18C3.38 11.11 1.5 14.12 1.5 17.5h21c0-3.38-1.88-6.39-4.9-8.02zM7 15.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
windows: (
|
windows: (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/>
|
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
macos: (
|
macos: (
|
||||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
linux: (
|
linux: (
|
||||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489.117.779.456 1.462 1.047 1.993.545.487 1.183.805 1.894.983.718.178 1.463.201 2.215.201.752 0 1.498-.023 2.215-.201.711-.178 1.349-.496 1.894-.983.591-.531.93-1.214 1.047-1.993.123-.805-.009-1.657-.287-2.489-.589-1.77-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298-.165-.013-.325-.021-.48-.021zm-.126 1.09c.99.063 1.783.569 2.172 1.443.376.844.413 1.903.199 2.999-.215 1.096-.687 2.23-1.357 3.18-.669.95-1.405 1.705-2.017 2.379-.611.674-1.074 1.252-1.316 1.857-.242.605-.262 1.233-.006 1.873.256.64.755 1.198 1.36 1.547.606.35 1.247.536 1.88.536.634 0 1.275-.186 1.88-.536.606-.349 1.105-.907 1.36-1.547.256-.64.236-1.268-.006-1.873-.242-.605-.705-1.183-1.316-1.857-.612-.674-1.348-1.429-2.017-2.379-.67-.95-1.142-2.084-1.357-3.18-.214-1.096-.177-2.155.199-2.999.389-.874 1.182-1.38 2.172-1.443z"/>
|
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489.117.779.456 1.462 1.047 1.993.545.487 1.183.805 1.894.983.718.178 1.463.201 2.215.201.752 0 1.498-.023 2.215-.201.711-.178 1.349-.496 1.894-.983.591-.531.93-1.214 1.047-1.993.123-.805-.009-1.657-.287-2.489-.589-1.77-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298-.165-.013-.325-.021-.48-.021zm-.126 1.09c.99.063 1.783.569 2.172 1.443.376.844.413 1.903.199 2.999-.215 1.096-.687 2.23-1.357 3.18-.669.95-1.405 1.705-2.017 2.379-.611.674-1.074 1.252-1.316 1.857-.242.605-.262 1.233-.006 1.873.256.64.755 1.198 1.36 1.547.606.35 1.247.536 1.88.536.634 0 1.275-.186 1.88-.536.606-.349 1.105-.907 1.36-1.547.256-.64.236-1.268-.006-1.873-.242-.605-.705-1.183-1.316-1.857-.612-.674-1.348-1.429-2.017-2.379-.67-.95-1.142-2.084-1.357-3.18-.214-1.096-.177-2.155.199-2.999.389-.874 1.182-1.38 2.172-1.443z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
androidTV: (
|
androidTV: (
|
||||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
|
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
appleTV: (
|
appleTV: (
|
||||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>
|
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Step 1: Platform selection
|
// Step 1: Platform selection
|
||||||
if (!selectedPlatform) {
|
if (!selectedPlatform) {
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
|
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
|
||||||
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
|
||||||
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectPlatform') || 'Выберите платформу'}</h2>
|
<h2 className="text-lg font-bold text-dark-100">
|
||||||
|
{t('subscription.connection.selectPlatform') || 'Выберите платформу'}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-2">
|
<div className="space-y-2 p-4">
|
||||||
{availablePlatforms.map(platform => {
|
{availablePlatforms.map((platform) => {
|
||||||
const apps = appConfig.platforms[platform]
|
const apps = appConfig.platforms[platform];
|
||||||
if (!apps?.length) return null
|
if (!apps?.length) return null;
|
||||||
const isCurrentPlatform = platform === detectedPlatform
|
const isCurrentPlatform = platform === detectedPlatform;
|
||||||
const appCount = apps.length
|
const appCount = apps.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={platform}
|
key={platform}
|
||||||
onClick={() => setSelectedPlatform(platform)}
|
onClick={() => setSelectedPlatform(platform)}
|
||||||
className={`w-full flex items-center gap-4 p-4 rounded-xl transition-all active:scale-[0.98] ${
|
className={`flex w-full items-center gap-4 rounded-xl p-4 transition-all active:scale-[0.98] ${
|
||||||
isCurrentPlatform
|
isCurrentPlatform
|
||||||
? 'bg-accent-500/10 ring-1 ring-accent-500/30'
|
? 'bg-accent-500/10 ring-1 ring-accent-500/30'
|
||||||
: 'bg-dark-800/50 hover:bg-dark-800'
|
: 'bg-dark-800/50 hover:bg-dark-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
<div
|
||||||
isCurrentPlatform ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700 text-dark-300'
|
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
|
||||||
}`}>
|
isCurrentPlatform
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'bg-dark-700 text-dark-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{platformIcons[platform] || <span className="text-xl">📱</span>}
|
{platformIcons[platform] || <span className="text-xl">📱</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`font-medium ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-100'}`}>
|
<span
|
||||||
|
className={`font-medium ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-100'}`}
|
||||||
|
>
|
||||||
{platformNames[platform] || platform}
|
{platformNames[platform] || platform}
|
||||||
</span>
|
</span>
|
||||||
{isCurrentPlatform && (
|
{isCurrentPlatform && (
|
||||||
<span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full">
|
<span className="rounded-full bg-accent-500/10 px-2 py-0.5 text-xs text-accent-500">
|
||||||
{t('subscription.connection.yourDevice')}
|
{t('subscription.connection.yourDevice')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-dark-400">
|
<span className="text-sm text-dark-400">
|
||||||
{appCount} {appCount === 1 ? (t('subscription.connection.app') || 'приложение') : (t('subscription.connection.apps') || 'приложений')}
|
{appCount}{' '}
|
||||||
|
{appCount === 1
|
||||||
|
? t('subscription.connection.app') || 'приложение'
|
||||||
|
: t('subscription.connection.apps') || 'приложений'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronIcon />
|
<ChevronIcon />
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: App selection for chosen platform
|
// Step 2: App selection for chosen platform
|
||||||
const apps = appConfig.platforms[selectedPlatform] || []
|
const apps = appConfig.platforms[selectedPlatform] || [];
|
||||||
const isCurrentPlatform = selectedPlatform === detectedPlatform
|
const isCurrentPlatform = selectedPlatform === detectedPlatform;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
|
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
|
||||||
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
|
||||||
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="font-bold text-dark-100 text-lg">{platformNames[selectedPlatform] || selectedPlatform}</h2>
|
<h2 className="text-lg font-bold text-dark-100">
|
||||||
|
{platformNames[selectedPlatform] || selectedPlatform}
|
||||||
|
</h2>
|
||||||
{isCurrentPlatform && (
|
{isCurrentPlatform && (
|
||||||
<span className="text-xs text-accent-500">{t('subscription.connection.yourDevice')}</span>
|
<span className="text-xs text-accent-500">
|
||||||
|
{t('subscription.connection.yourDevice')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,13 +542,17 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
onWheel={handleScrollContainerWheel}
|
onWheel={handleScrollContainerWheel}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{apps.map(app => {
|
{apps.map((app) => {
|
||||||
const isSelected = selectedApp?.id === app.id
|
const isSelected = selectedApp?.id === app.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={app.id}
|
key={app.id}
|
||||||
onClick={() => { setSelectedApp(app); setShowAppSelector(false); setSelectedPlatform(null) }}
|
onClick={() => {
|
||||||
className={`relative flex flex-col items-center p-4 rounded-xl transition-all active:scale-95 ${
|
setSelectedApp(app);
|
||||||
|
setShowAppSelector(false);
|
||||||
|
setSelectedPlatform(null);
|
||||||
|
}}
|
||||||
|
className={`relative flex flex-col items-center rounded-xl p-4 transition-all active:scale-95 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-accent-500/15 ring-2 ring-accent-500/50'
|
? 'bg-accent-500/15 ring-2 ring-accent-500/50'
|
||||||
: 'bg-dark-800/50 hover:bg-dark-800'
|
: 'bg-dark-800/50 hover:bg-dark-800'
|
||||||
@@ -484,85 +560,107 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
>
|
>
|
||||||
{/* Featured badge */}
|
{/* Featured badge */}
|
||||||
{app.isFeatured && (
|
{app.isFeatured && (
|
||||||
<div className="absolute -top-1.5 -right-1.5 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-lg">
|
<div className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-accent-500 shadow-lg">
|
||||||
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
|
className="h-3.5 w-3.5 text-white"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* App icon */}
|
{/* App icon */}
|
||||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 transition-colors ${
|
<div
|
||||||
|
className={`mb-3 flex h-14 w-14 items-center justify-center rounded-2xl transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-accent-500/20 text-accent-400'
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
: 'bg-dark-700/80 text-dark-300'
|
: 'bg-dark-700/80 text-dark-300'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{getAppIcon(app.name)}
|
{getAppIcon(app.name)}
|
||||||
</div>
|
</div>
|
||||||
{/* App name */}
|
{/* App name */}
|
||||||
<span className={`text-sm font-medium text-center leading-tight line-clamp-2 ${
|
<span
|
||||||
|
className={`line-clamp-2 text-center text-sm font-medium leading-tight ${
|
||||||
isSelected ? 'text-accent-400' : 'text-dark-200'
|
isSelected ? 'text-accent-400' : 'text-dark-200'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{app.name}
|
{app.name}
|
||||||
</span>
|
</span>
|
||||||
{/* Selection indicator */}
|
{/* Selection indicator */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-2 h-2 bg-accent-500 rounded-full" />
|
<div className="absolute bottom-2 left-1/2 h-2 w-2 -translate-x-1/2 rounded-full bg-accent-500" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main view
|
// Main view
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<div className="p-4 border-b border-dark-800">
|
<div className="border-b border-dark-800 p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
|
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
|
||||||
<button onClick={handleClose} className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400">
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
|
||||||
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAppSelector(true)}
|
onClick={() => setShowAppSelector(true)}
|
||||||
className="w-full flex items-center gap-3 p-3 rounded-xl bg-dark-800/50 hover:bg-dark-800 transition-colors"
|
className="flex w-full items-center gap-3 rounded-xl bg-dark-800/50 p-3 transition-colors hover:bg-dark-800"
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center text-accent-400">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/10 text-accent-400">
|
||||||
{selectedApp && getAppIcon(selectedApp.name)}
|
{selectedApp && getAppIcon(selectedApp.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<div className="font-medium text-dark-100">{selectedApp?.name}</div>
|
<div className="font-medium text-dark-100">{selectedApp?.name}</div>
|
||||||
<div className="text-sm text-dark-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div>
|
<div className="text-sm text-dark-400">
|
||||||
|
{t('subscription.connection.changeApp') || 'Сменить приложение'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronIcon />
|
<ChevronIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} overflow-y-auto p-4 space-y-4`}
|
className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} space-y-4 overflow-y-auto p-4`}
|
||||||
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
|
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
|
||||||
onWheel={handleScrollContainerWheel}
|
onWheel={handleScrollContainerWheel}
|
||||||
>
|
>
|
||||||
{selectedApp?.installationStep && (
|
{selectedApp?.installationStep && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">1</span>
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/20 text-xs font-bold text-accent-400">
|
||||||
<span className="font-medium text-dark-100">{t('subscription.connection.installApp')}</span>
|
1
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-dark-100">
|
||||||
|
{t('subscription.connection.installApp')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.installationStep.description)}</p>
|
<p className="ml-8 text-sm text-dark-400">
|
||||||
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
|
{getLocalizedText(selectedApp.installationStep.description)}
|
||||||
<div className="flex flex-wrap gap-2 ml-8">
|
</p>
|
||||||
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => (
|
{selectedApp.installationStep.buttons &&
|
||||||
|
selectedApp.installationStep.buttons.length > 0 && (
|
||||||
|
<div className="ml-8 flex flex-wrap gap-2">
|
||||||
|
{selectedApp.installationStep.buttons
|
||||||
|
.filter((btn) => isValidExternalUrl(btn.buttonLink))
|
||||||
|
.map((btn, idx) => (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
href={btn.buttonLink}
|
href={btn.buttonLink}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-800 text-dark-200 text-sm hover:bg-dark-700"
|
className="inline-flex items-center gap-1.5 rounded-lg bg-dark-800 px-3 py-1.5 text-sm text-dark-200 hover:bg-dark-700"
|
||||||
>
|
>
|
||||||
{getLocalizedText(btn.buttonText)}
|
{getLocalizedText(btn.buttonText)}
|
||||||
</a>
|
</a>
|
||||||
@@ -575,15 +673,21 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
{selectedApp?.addSubscriptionStep && (
|
{selectedApp?.addSubscriptionStep && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">2</span>
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/20 text-xs font-bold text-accent-400">
|
||||||
<span className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</span>
|
2
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-dark-100">
|
||||||
|
{t('subscription.connection.addSubscription')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
|
<p className="ml-8 text-sm text-dark-400">
|
||||||
<div className="space-y-2 ml-8">
|
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
|
||||||
|
</p>
|
||||||
|
<div className="ml-8 space-y-2">
|
||||||
{selectedApp.deepLink && (
|
{selectedApp.deepLink && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleConnect(selectedApp)}
|
onClick={() => handleConnect(selectedApp)}
|
||||||
className="w-full btn-primary h-11 text-sm font-semibold flex items-center justify-center gap-2"
|
className="btn-primary flex h-11 w-full items-center justify-center gap-2 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
<LinkIcon />
|
<LinkIcon />
|
||||||
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
|
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
|
||||||
@@ -593,14 +697,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
{!appConfig?.hideLink && (
|
{!appConfig?.hideLink && (
|
||||||
<button
|
<button
|
||||||
onClick={copySubscriptionLink}
|
onClick={copySubscriptionLink}
|
||||||
className={`w-full h-11 rounded-xl border transition-all flex items-center justify-center gap-2 text-sm font-medium ${
|
className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl border text-sm font-medium transition-all ${
|
||||||
copied
|
copied
|
||||||
? 'border-success-500 bg-success-500/10 text-success-400'
|
? 'border-success-500 bg-success-500/10 text-success-400'
|
||||||
: 'border-dark-600 text-dark-300 hover:bg-dark-800'
|
: 'border-dark-600 text-dark-300 hover:bg-dark-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
|
{copied
|
||||||
|
? t('subscription.connection.copied')
|
||||||
|
: t('subscription.connection.copyLink')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -610,13 +716,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
|||||||
{selectedApp?.connectAndUseStep && (
|
{selectedApp?.connectAndUseStep && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-6 h-6 rounded-full bg-success-500/20 flex items-center justify-center text-xs font-bold text-success-400">3</span>
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-success-500/20 text-xs font-bold text-success-400">
|
||||||
<span className="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</span>
|
3
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-dark-100">
|
||||||
|
{t('subscription.connection.connectVpn')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p>
|
<p className="ml-8 text-sm text-dark-400">
|
||||||
|
{getLocalizedText(selectedApp.connectAndUseStep.description)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { balanceApi } from '../api/balance'
|
import { balanceApi } from '../api/balance';
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import TopUpModal from './TopUpModal'
|
import TopUpModal from './TopUpModal';
|
||||||
import type { PaymentMethod } from '../types'
|
import type { PaymentMethod } from '../types';
|
||||||
|
|
||||||
interface InsufficientBalancePromptProps {
|
interface InsufficientBalancePromptProps {
|
||||||
/** Amount missing in kopeks */
|
/** Amount missing in kopeks */
|
||||||
missingAmountKopeks: number
|
missingAmountKopeks: number;
|
||||||
/** Optional custom message */
|
/** Optional custom message */
|
||||||
message?: string
|
message?: string;
|
||||||
/** Compact mode for inline use */
|
/** Compact mode for inline use */
|
||||||
compact?: boolean
|
compact?: boolean;
|
||||||
/** Additional className */
|
/** Additional className */
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InsufficientBalancePrompt({
|
export default function InsufficientBalancePrompt({
|
||||||
@@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({
|
|||||||
compact = false,
|
compact = false,
|
||||||
className = '',
|
className = '',
|
||||||
}: InsufficientBalancePromptProps) {
|
}: InsufficientBalancePromptProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const { formatAmount, currencySymbol } = useCurrency()
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const [showMethodSelect, setShowMethodSelect] = useState(false)
|
const [showMethodSelect, setShowMethodSelect] = useState(false);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery({
|
const { data: paymentMethods } = useQuery({
|
||||||
queryKey: ['payment-methods'],
|
queryKey: ['payment-methods'],
|
||||||
queryFn: balanceApi.getPaymentMethods,
|
queryFn: balanceApi.getPaymentMethods,
|
||||||
enabled: showMethodSelect,
|
enabled: showMethodSelect,
|
||||||
})
|
});
|
||||||
|
|
||||||
const missingRubles = missingAmountKopeks / 100
|
const missingRubles = missingAmountKopeks / 100;
|
||||||
const displayAmount = formatAmount(missingRubles)
|
const displayAmount = formatAmount(missingRubles);
|
||||||
|
|
||||||
const handleMethodSelect = (method: PaymentMethod) => {
|
const handleMethodSelect = (method: PaymentMethod) => {
|
||||||
setSelectedMethod(method)
|
setSelectedMethod(method);
|
||||||
setShowMethodSelect(false)
|
setShowMethodSelect(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`flex items-center justify-between gap-3 p-3 bg-error-500/10 border border-error-500/30 rounded-xl ${className}`}>
|
<div
|
||||||
|
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 text-sm text-error-400">
|
<div className="flex items-center gap-2 text-sm text-error-400">
|
||||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
className="h-4 w-4 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span>
|
{message || t('balance.insufficientFunds')}:{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{displayAmount} {currencySymbol}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMethodSelect(true)}
|
onClick={() => setShowMethodSelect(true)}
|
||||||
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap"
|
className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{t('balance.topUp')}
|
{t('balance.topUp')}
|
||||||
</button>
|
</button>
|
||||||
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`p-4 bg-gradient-to-br from-error-500/10 to-warning-500/5 border border-error-500/30 rounded-xl ${className}`}>
|
<div
|
||||||
|
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
|
||||||
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
|
||||||
<svg className="w-5 h-5 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
className="h-5 w-5 text-error-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-error-400 font-medium mb-1">
|
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
|
||||||
{t('balance.insufficientFunds')}
|
<div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
|
||||||
</div>
|
|
||||||
<div className="text-dark-300 text-sm">
|
|
||||||
{message || t('balance.topUpToComplete')}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div className="mt-3 flex items-center gap-3">
|
||||||
<div className="text-lg font-bold text-dark-100">
|
<div className="text-lg font-bold text-dark-100">
|
||||||
{t('balance.missing')}: <span className="text-error-400">{displayAmount} {currencySymbol}</span>
|
{t('balance.missing')}:{' '}
|
||||||
|
<span className="text-error-400">
|
||||||
|
{displayAmount} {currencySymbol}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMethodSelect(true)}
|
onClick={() => setShowMethodSelect(true)}
|
||||||
className="btn-primary w-full mt-4 py-2.5 flex items-center justify-center gap-2"
|
className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
{t('balance.topUpBalance')}
|
{t('balance.topUpBalance')}
|
||||||
@@ -131,80 +163,115 @@ export default function InsufficientBalancePrompt({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentMethodModalProps {
|
interface PaymentMethodModalProps {
|
||||||
paymentMethods: PaymentMethod[] | undefined
|
paymentMethods: PaymentMethod[] | undefined;
|
||||||
onSelect: (method: PaymentMethod) => void
|
onSelect: (method: PaymentMethod) => void;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
|
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const { formatAmount, currencySymbol } = useCurrency()
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0">
|
||||||
<div className="absolute inset-0" onClick={onClose} />
|
<div className="absolute inset-0" onClick={onClose} />
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
|
<div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
<div className="flex items-center justify-between bg-dark-800/50 px-4 py-3">
|
||||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
|
<button
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
{!paymentMethods ? (
|
{!paymentMethods ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : paymentMethods.length === 0 ? (
|
) : paymentMethods.length === 0 ? (
|
||||||
<div className="text-center py-6 text-dark-400 text-sm">
|
<div className="py-6 text-center text-sm text-dark-400">
|
||||||
{t('balance.noPaymentMethods')}
|
{t('balance.noPaymentMethods')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paymentMethods.map((method) => {
|
paymentMethods.map((method) => {
|
||||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
|
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={method.id}
|
key={method.id}
|
||||||
disabled={!method.is_available}
|
disabled={!method.is_available}
|
||||||
onClick={() => method.is_available && onSelect(method)}
|
onClick={() => method.is_available && onSelect(method)}
|
||||||
className={`w-full p-3 rounded-xl text-left flex items-center gap-3 ${
|
className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
|
||||||
method.is_available
|
method.is_available
|
||||||
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
|
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
|
||||||
: 'bg-dark-800/50 opacity-50'
|
: 'bg-dark-800/50 opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-lg bg-accent-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
|
||||||
<svg className="w-4 h-4 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
className="h-4 w-4 text-accent-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div>
|
<div className="text-sm font-medium text-dark-100">
|
||||||
|
{translatedName || method.name}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{formatAmount(method.min_amount_kopeks / 100, 0)} – {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||||
|
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg className="w-4 h-4 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
className="h-4 w-4 text-dark-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
|
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
|
||||||
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
|
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
|
||||||
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
|
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
|
||||||
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
|
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
|
||||||
]
|
];
|
||||||
|
|
||||||
export default function LanguageSwitcher() {
|
export default function LanguageSwitcher() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0]
|
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const changeLanguage = (code: string) => {
|
const changeLanguage = (code: string) => {
|
||||||
i18n.changeLanguage(code)
|
i18n.changeLanguage(code);
|
||||||
// Set document direction for RTL languages
|
// Set document direction for RTL languages
|
||||||
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'
|
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Set initial direction on mount
|
// Set initial direction on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'
|
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
|
||||||
}, [i18n.language])
|
}, [i18n.language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 hover:bg-dark-700 transition-all text-sm"
|
className="flex items-center gap-1.5 rounded-xl border border-dark-700/50 bg-dark-800/50 px-2.5 py-2 text-sm transition-all hover:border-dark-600 hover:bg-dark-700"
|
||||||
aria-label="Change language"
|
aria-label="Change language"
|
||||||
>
|
>
|
||||||
<span>{currentLang.flag}</span>
|
<span>{currentLang.flag}</span>
|
||||||
<span className="font-medium text-dark-200">{currentLang.name}</span>
|
<span className="font-medium text-dark-200">{currentLang.name}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -57,12 +57,12 @@ export default function LanguageSwitcher() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 mt-2 w-40 bg-dark-800 rounded-xl shadow-lg border border-dark-700/50 py-1 z-50 animate-fade-in">
|
<div className="absolute right-0 z-50 mt-2 w-40 animate-fade-in rounded-xl border border-dark-700/50 bg-dark-800 py-1 shadow-lg">
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => changeLanguage(lang.code)}
|
onClick={() => changeLanguage(lang.code)}
|
||||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||||
lang.code === i18n.language
|
lang.code === i18n.language
|
||||||
? 'bg-accent-500/10 text-accent-400'
|
? 'bg-accent-500/10 text-accent-400'
|
||||||
: 'text-dark-300 hover:bg-dark-700/50'
|
: 'text-dark-300 hover:bg-dark-700/50'
|
||||||
@@ -75,5 +75,5 @@ export default function LanguageSwitcher() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +1,147 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface OnboardingStep {
|
interface OnboardingStep {
|
||||||
target: string // data-onboarding attribute value
|
target: string; // data-onboarding attribute value
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
placement: 'top' | 'bottom' | 'left' | 'right'
|
placement: 'top' | 'bottom' | 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OnboardingProps {
|
interface OnboardingProps {
|
||||||
steps: OnboardingStep[]
|
steps: OnboardingStep[];
|
||||||
onComplete: () => void
|
onComplete: () => void;
|
||||||
onSkip: () => void
|
onSkip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'onboarding_completed'
|
const STORAGE_KEY = 'onboarding_completed';
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function useOnboarding() {
|
export function useOnboarding() {
|
||||||
const [isCompleted, setIsCompleted] = useState(() => {
|
const [isCompleted, setIsCompleted] = useState(() => {
|
||||||
return localStorage.getItem(STORAGE_KEY) === 'true'
|
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||||
})
|
});
|
||||||
|
|
||||||
const complete = useCallback(() => {
|
const complete = useCallback(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, 'true')
|
localStorage.setItem(STORAGE_KEY, 'true');
|
||||||
setIsCompleted(true)
|
setIsCompleted(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
localStorage.removeItem(STORAGE_KEY)
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
setIsCompleted(false)
|
setIsCompleted(false);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return { isCompleted, complete, reset }
|
return { isCompleted, complete, reset };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
|
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const step = steps[currentStep]
|
const step = steps[currentStep];
|
||||||
|
|
||||||
// Find and highlight target element
|
// Find and highlight target element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const findTarget = () => {
|
const findTarget = () => {
|
||||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
|
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||||
if (target) {
|
if (target) {
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect();
|
||||||
setTargetRect(rect)
|
setTargetRect(rect);
|
||||||
|
|
||||||
// Scroll element into view if needed
|
// Scroll element into view if needed
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
// Delay visibility for smooth animation
|
// Delay visibility for smooth animation
|
||||||
setTimeout(() => setIsVisible(true), 100)
|
setTimeout(() => setIsVisible(true), 100);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setIsVisible(false)
|
setIsVisible(false);
|
||||||
const timer = setTimeout(findTarget, 300)
|
const timer = setTimeout(findTarget, 300);
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer);
|
||||||
}, [step.target])
|
}, [step.target]);
|
||||||
|
|
||||||
// Recalculate position on resize/scroll
|
// Recalculate position on resize/scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
|
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||||
if (target) {
|
if (target) {
|
||||||
setTargetRect(target.getBoundingClientRect())
|
setTargetRect(target.getBoundingClientRect());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', updatePosition)
|
window.addEventListener('resize', updatePosition);
|
||||||
window.addEventListener('scroll', updatePosition, true)
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', updatePosition)
|
window.removeEventListener('resize', updatePosition);
|
||||||
window.removeEventListener('scroll', updatePosition, true)
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
}
|
};
|
||||||
}, [step.target])
|
}, [step.target]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (currentStep < steps.length - 1) {
|
if (currentStep < steps.length - 1) {
|
||||||
setCurrentStep(currentStep + 1)
|
setCurrentStep(currentStep + 1);
|
||||||
} else {
|
} else {
|
||||||
onComplete()
|
onComplete();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = () => {
|
||||||
if (currentStep > 0) {
|
if (currentStep > 0) {
|
||||||
setCurrentStep(currentStep - 1)
|
setCurrentStep(currentStep - 1);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
onSkip()
|
onSkip();
|
||||||
}
|
};
|
||||||
|
|
||||||
// Calculate tooltip position
|
// Calculate tooltip position
|
||||||
const getTooltipStyle = (): React.CSSProperties => {
|
const getTooltipStyle = (): React.CSSProperties => {
|
||||||
if (!targetRect) return { opacity: 0 }
|
if (!targetRect) return { opacity: 0 };
|
||||||
|
|
||||||
const padding = 16
|
const padding = 16;
|
||||||
const tooltipWidth = 320
|
const tooltipWidth = 320;
|
||||||
const tooltipHeight = tooltipRef.current?.offsetHeight || 150
|
const tooltipHeight = tooltipRef.current?.offsetHeight || 150;
|
||||||
|
|
||||||
let top = 0
|
let top = 0;
|
||||||
let left = 0
|
let left = 0;
|
||||||
|
|
||||||
switch (step.placement) {
|
switch (step.placement) {
|
||||||
case 'bottom':
|
case 'bottom':
|
||||||
top = targetRect.bottom + padding
|
top = targetRect.bottom + padding;
|
||||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
|
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
||||||
break
|
break;
|
||||||
case 'top':
|
case 'top':
|
||||||
top = targetRect.top - tooltipHeight - padding
|
top = targetRect.top - tooltipHeight - padding;
|
||||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
|
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
||||||
break
|
break;
|
||||||
case 'left':
|
case 'left':
|
||||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
|
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
||||||
left = targetRect.left - tooltipWidth - padding
|
left = targetRect.left - tooltipWidth - padding;
|
||||||
break
|
break;
|
||||||
case 'right':
|
case 'right':
|
||||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
|
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
||||||
left = targetRect.right + padding
|
left = targetRect.right + padding;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep within viewport
|
// Keep within viewport
|
||||||
const viewportWidth = window.innerWidth
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
if (left < padding) left = padding
|
if (left < padding) left = padding;
|
||||||
if (left + tooltipWidth > viewportWidth - padding) {
|
if (left + tooltipWidth > viewportWidth - padding) {
|
||||||
left = viewportWidth - tooltipWidth - padding
|
left = viewportWidth - tooltipWidth - padding;
|
||||||
}
|
}
|
||||||
if (top < padding) top = padding
|
if (top < padding) top = padding;
|
||||||
if (top + tooltipHeight > viewportHeight - padding) {
|
if (top + tooltipHeight > viewportHeight - padding) {
|
||||||
top = viewportHeight - tooltipHeight - padding
|
top = viewportHeight - tooltipHeight - padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -149,22 +150,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
width: tooltipWidth,
|
width: tooltipWidth,
|
||||||
opacity: isVisible ? 1 : 0,
|
opacity: isVisible ? 1 : 0,
|
||||||
transform: isVisible ? 'scale(1)' : 'scale(0.95)',
|
transform: isVisible ? 'scale(1)' : 'scale(0.95)',
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Spotlight style
|
// Spotlight style
|
||||||
const getSpotlightStyle = (): React.CSSProperties => {
|
const getSpotlightStyle = (): React.CSSProperties => {
|
||||||
if (!targetRect) return { opacity: 0 }
|
if (!targetRect) return { opacity: 0 };
|
||||||
|
|
||||||
const padding = 8
|
const padding = 8;
|
||||||
return {
|
return {
|
||||||
top: targetRect.top - padding,
|
top: targetRect.top - padding,
|
||||||
left: targetRect.left - padding,
|
left: targetRect.left - padding,
|
||||||
width: targetRect.width + padding * 2,
|
width: targetRect.width + padding * 2,
|
||||||
height: targetRect.height + padding * 2,
|
height: targetRect.height + padding * 2,
|
||||||
opacity: isVisible ? 1 : 0,
|
opacity: isVisible ? 1 : 0,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
||||||
@@ -178,7 +179,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
style={getTooltipStyle()}
|
style={getTooltipStyle()}
|
||||||
>
|
>
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<div className="flex items-center gap-1.5 mb-4">
|
<div className="mb-4 flex items-center gap-1.5">
|
||||||
{steps.map((s, index) => (
|
{steps.map((s, index) => (
|
||||||
<div
|
<div
|
||||||
key={s.target}
|
key={s.target}
|
||||||
@@ -194,25 +195,25 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<h3 className="text-lg font-semibold text-dark-50 mb-2">{step.title}</h3>
|
<h3 className="mb-2 text-lg font-semibold text-dark-50">{step.title}</h3>
|
||||||
<p className="text-dark-400 text-sm mb-5">{step.description}</p>
|
<p className="mb-5 text-sm text-dark-400">{step.description}</p>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="text-dark-500 hover:text-dark-300 text-sm transition-colors"
|
className="text-sm text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
{t('onboarding.skip', 'Skip')}
|
{t('onboarding.skip', 'Skip')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{currentStep > 0 && (
|
{currentStep > 0 && (
|
||||||
<button onClick={handlePrev} className="btn-ghost text-sm px-3 py-1.5">
|
<button onClick={handlePrev} className="btn-ghost px-3 py-1.5 text-sm">
|
||||||
{t('common.back', 'Back')}
|
{t('common.back', 'Back')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleNext} className="btn-primary text-sm px-4 py-1.5">
|
<button onClick={handleNext} className="btn-primary px-4 py-1.5 text-sm">
|
||||||
{currentStep === steps.length - 1
|
{currentStep === steps.length - 1
|
||||||
? t('onboarding.finish', 'Finish')
|
? t('onboarding.finish', 'Finish')
|
||||||
: t('common.next', 'Next')}
|
: t('common.next', 'Next')}
|
||||||
@@ -235,6 +236,6 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,111 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { promoApi } from '../api/promo'
|
import { promoApi } from '../api/promo';
|
||||||
|
|
||||||
const SparklesIcon = () => (
|
const SparklesIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ClockIcon = () => (
|
const ClockIcon = () => (
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||||
let expires: Date
|
let expires: Date;
|
||||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||||
expires = new Date(expiresAt)
|
expires = new Date(expiresAt);
|
||||||
} else {
|
} else {
|
||||||
// No timezone - treat as UTC
|
// No timezone - treat as UTC
|
||||||
expires = new Date(expiresAt + 'Z')
|
expires = new Date(expiresAt + 'Z');
|
||||||
}
|
}
|
||||||
const diffMs = expires.getTime() - now.getTime()
|
const diffMs = expires.getTime() - now.getTime();
|
||||||
|
|
||||||
if (diffMs <= 0) return ''
|
if (diffMs <= 0) return '';
|
||||||
|
|
||||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
if (hours > 24) {
|
if (hours > 24) {
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24);
|
||||||
return `${days}${t('promo.time.days')}`
|
return `${days}${t('promo.time.days')}`;
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`
|
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
|
||||||
}
|
}
|
||||||
return `${minutes}${t('promo.time.minutes')}`
|
return `${minutes}${t('promo.time.minutes')}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function PromoDiscountBadge() {
|
export default function PromoDiscountBadge() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: activeDiscount } = useQuery({
|
const { data: activeDiscount } = useQuery({
|
||||||
queryKey: ['active-discount'],
|
queryKey: ['active-discount'],
|
||||||
queryFn: promoApi.getActiveDiscount,
|
queryFn: promoApi.getActiveDiscount,
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
refetchInterval: 60000, // Refresh every minute
|
refetchInterval: 60000, // Refresh every minute
|
||||||
})
|
});
|
||||||
|
|
||||||
// Close dropdown on click outside
|
// Close dropdown on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Don't render if no active discount
|
// Don't render if no active discount
|
||||||
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
|
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null
|
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setIsOpen(!isOpen)
|
setIsOpen(!isOpen);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleGoToSubscription = () => {
|
const handleGoToSubscription = () => {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
navigate('/subscription')
|
navigate('/subscription');
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
{/* Badge button */}
|
{/* Badge button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="relative flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-success-500/15 hover:bg-success-500/25 transition-all group"
|
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
|
||||||
title={t('promo.activeDiscount', 'Active discount')}
|
title={t('promo.activeDiscount', 'Active discount')}
|
||||||
>
|
>
|
||||||
<span className="font-bold text-success-400 text-sm">
|
<span className="text-sm font-bold text-success-400">
|
||||||
-{activeDiscount.discount_percent}%
|
-{activeDiscount.discount_percent}%
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -101,21 +115,19 @@ export default function PromoDiscountBadge() {
|
|||||||
<>
|
<>
|
||||||
{/* Mobile backdrop */}
|
{/* Mobile backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/30 z-40 sm:hidden"
|
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="fixed left-4 right-4 top-20 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72 bg-dark-800 rounded-xl shadow-xl border border-dark-700/50 z-50 animate-fade-in overflow-hidden">
|
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4 border-b border-dark-700/50">
|
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-success-500/20 flex items-center justify-center text-success-400">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
|
||||||
<SparklesIcon />
|
<SparklesIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-dark-100">
|
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
|
||||||
{t('promo.discountActive')}
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-success-400">
|
<div className="text-2xl font-bold text-success-400">
|
||||||
-{activeDiscount.discount_percent}%
|
-{activeDiscount.discount_percent}%
|
||||||
</div>
|
</div>
|
||||||
@@ -124,23 +136,24 @@ export default function PromoDiscountBadge() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="space-y-3 p-4">
|
||||||
<p className="text-sm text-dark-300">
|
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
|
||||||
{t('promo.discountDescription')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Time remaining */}
|
{/* Time remaining */}
|
||||||
{timeLeft && (
|
{timeLeft && (
|
||||||
<div className="flex items-center gap-2 text-sm text-dark-400 bg-dark-900/50 px-3 py-2 rounded-lg">
|
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
<span>{t('promo.expiresIn')}: <span className="text-warning-400 font-medium">{timeLeft}</span></span>
|
<span>
|
||||||
|
{t('promo.expiresIn')}:{' '}
|
||||||
|
<span className="font-medium text-warning-400">{timeLeft}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleGoToSubscription}
|
onClick={handleGoToSubscription}
|
||||||
className="w-full btn-primary py-2.5 text-sm font-medium"
|
className="btn-primary w-full py-2.5 text-sm font-medium"
|
||||||
>
|
>
|
||||||
{t('promo.useNow')}
|
{t('promo.useNow')}
|
||||||
</button>
|
</button>
|
||||||
@@ -149,5 +162,5 @@ export default function PromoDiscountBadge() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +1,165 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { promoApi, PromoOffer } from '../api/promo'
|
import { promoApi, PromoOffer } from '../api/promo';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const GiftIcon = () => (
|
const GiftIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ClockIcon = () => (
|
const ClockIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SparklesIcon = () => (
|
const SparklesIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ServerIcon = () => (
|
const ServerIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatTimeLeft = (expiresAt: string): string => {
|
const formatTimeLeft = (expiresAt: string): string => {
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||||
let expires: Date
|
let expires: Date;
|
||||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||||
expires = new Date(expiresAt)
|
expires = new Date(expiresAt);
|
||||||
} else {
|
} else {
|
||||||
// No timezone - treat as UTC
|
// No timezone - treat as UTC
|
||||||
expires = new Date(expiresAt + 'Z')
|
expires = new Date(expiresAt + 'Z');
|
||||||
}
|
}
|
||||||
const diffMs = expires.getTime() - now.getTime()
|
const diffMs = expires.getTime() - now.getTime();
|
||||||
|
|
||||||
if (diffMs <= 0) return 'Истекло'
|
if (diffMs <= 0) return 'Истекло';
|
||||||
|
|
||||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
if (hours > 24) {
|
if (hours > 24) {
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24);
|
||||||
return `${days} дн.`
|
return `${days} дн.`;
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
return `${hours}ч ${minutes}м`
|
return `${hours}ч ${minutes}м`;
|
||||||
}
|
}
|
||||||
return `${minutes}м`
|
return `${minutes}м`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getOfferIcon = (effectType: string) => {
|
const getOfferIcon = (effectType: string) => {
|
||||||
if (effectType === 'test_access') return <ServerIcon />
|
if (effectType === 'test_access') return <ServerIcon />;
|
||||||
return <SparklesIcon />
|
return <SparklesIcon />;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getOfferTitle = (offer: PromoOffer): string => {
|
const getOfferTitle = (offer: PromoOffer): string => {
|
||||||
if (offer.effect_type === 'test_access') {
|
if (offer.effect_type === 'test_access') {
|
||||||
return 'Тестовый доступ'
|
return 'Тестовый доступ';
|
||||||
}
|
}
|
||||||
if (offer.discount_percent) {
|
if (offer.discount_percent) {
|
||||||
return `Скидка ${offer.discount_percent}%`
|
return `Скидка ${offer.discount_percent}%`;
|
||||||
}
|
}
|
||||||
return 'Специальное предложение'
|
return 'Специальное предложение';
|
||||||
}
|
};
|
||||||
|
|
||||||
const getOfferDescription = (offer: PromoOffer): string => {
|
const getOfferDescription = (offer: PromoOffer): string => {
|
||||||
if (offer.effect_type === 'test_access') {
|
if (offer.effect_type === 'test_access') {
|
||||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0
|
const squads = offer.extra_data?.test_squad_uuids?.length || 0;
|
||||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'
|
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
|
||||||
}
|
}
|
||||||
return 'Активируйте скидку на покупку подписки'
|
return 'Активируйте скидку на покупку подписки';
|
||||||
}
|
};
|
||||||
|
|
||||||
interface PromoOffersSectionProps {
|
interface PromoOffersSectionProps {
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const [claimingId, setClaimingId] = useState<number | null>(null)
|
const [claimingId, setClaimingId] = useState<number | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch available offers
|
// Fetch available offers
|
||||||
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
||||||
queryKey: ['promo-offers'],
|
queryKey: ['promo-offers'],
|
||||||
queryFn: promoApi.getOffers,
|
queryFn: promoApi.getOffers,
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch active discount
|
// Fetch active discount
|
||||||
const { data: activeDiscount } = useQuery({
|
const { data: activeDiscount } = useQuery({
|
||||||
queryKey: ['active-discount'],
|
queryKey: ['active-discount'],
|
||||||
queryFn: promoApi.getActiveDiscount,
|
queryFn: promoApi.getActiveDiscount,
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Claim offer mutation
|
// Claim offer mutation
|
||||||
const claimMutation = useMutation({
|
const claimMutation = useMutation({
|
||||||
mutationFn: promoApi.claimOffer,
|
mutationFn: promoApi.claimOffer,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] })
|
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] })
|
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
setSuccessMessage(result.message)
|
setSuccessMessage(result.message);
|
||||||
setClaimingId(null)
|
setClaimingId(null);
|
||||||
setTimeout(() => setSuccessMessage(null), 5000)
|
setTimeout(() => setSuccessMessage(null), 5000);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: unknown) => {
|
||||||
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение')
|
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||||
setClaimingId(null)
|
setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
|
||||||
setTimeout(() => setErrorMessage(null), 5000)
|
setClaimingId(null);
|
||||||
|
setTimeout(() => setErrorMessage(null), 5000);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleClaim = (offerId: number) => {
|
const handleClaim = (offerId: number) => {
|
||||||
setClaimingId(offerId)
|
setClaimingId(offerId);
|
||||||
setErrorMessage(null)
|
setErrorMessage(null);
|
||||||
setSuccessMessage(null)
|
setSuccessMessage(null);
|
||||||
claimMutation.mutate(offerId)
|
claimMutation.mutate(offerId);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Filter unclaimed and active offers
|
// Filter unclaimed and active offers
|
||||||
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed)
|
const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
|
||||||
|
|
||||||
// Don't render if no offers and no active discount
|
// Don't render if no offers and no active discount
|
||||||
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) {
|
if (
|
||||||
return null
|
!offersLoading &&
|
||||||
|
availableOffers.length === 0 &&
|
||||||
|
(!activeDiscount || !activeDiscount.is_active)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
||||||
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400">
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20 text-accent-400">
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-dark-100">
|
<h3 className="font-semibold text-dark-100">
|
||||||
Скидка {activeDiscount.discount_percent}% активна
|
Скидка {activeDiscount.discount_percent}% активна
|
||||||
</h3>
|
</h3>
|
||||||
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded">
|
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||||
Действует
|
Действует
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
|
|
||||||
{/* Success/Error Messages */}
|
{/* Success/Error Messages */}
|
||||||
{successMessage && (
|
{successMessage && (
|
||||||
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3">
|
<div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-success-400">
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
<span>{successMessage}</span>
|
<span>{successMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl">
|
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-error-400">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
{availableOffers.map((offer) => (
|
{availableOffers.map((offer) => (
|
||||||
<div
|
<div
|
||||||
key={offer.id}
|
key={offer.id}
|
||||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors"
|
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400">
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-orange-400">
|
||||||
{getOfferIcon(offer.effect_type)}
|
{getOfferIcon(offer.effect_type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-dark-100">
|
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
|
||||||
{getOfferTitle(offer)}
|
|
||||||
</h3>
|
|
||||||
{offer.effect_type === 'test_access' && (
|
{offer.effect_type === 'test_access' && (
|
||||||
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded">
|
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||||
Тест
|
Тест
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-dark-400 mb-3">
|
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
|
||||||
{getOfferDescription(offer)}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||||
<ClockIcon />
|
<ClockIcon />
|
||||||
@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleClaim(offer.id)}
|
onClick={() => handleClaim(offer.id)}
|
||||||
disabled={claimingId === offer.id}
|
disabled={claimingId === offer.id}
|
||||||
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{claimingId === offer.id ? (
|
{claimingId === offer.id ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
<span>Активация...</span>
|
<span>Активация...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -246,14 +263,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
{offersLoading && (
|
{offersLoading && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" />
|
<div className="h-12 w-12 animate-pulse rounded-xl bg-dark-700" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" />
|
<div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
|
||||||
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" />
|
<div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,45 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface TelegramLoginButtonProps {
|
interface TelegramLoginButtonProps {
|
||||||
botUsername: string
|
botUsername: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TelegramLoginButton({
|
export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) {
|
||||||
botUsername,
|
const { t } = useTranslation();
|
||||||
}: TelegramLoginButtonProps) {
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { t } = useTranslation()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Load widget script
|
// Load widget script
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !botUsername) return
|
if (!containerRef.current || !botUsername) return;
|
||||||
|
|
||||||
// Clear previous widget using safe DOM API
|
// Clear previous widget using safe DOM API
|
||||||
while (containerRef.current.firstChild) {
|
while (containerRef.current.firstChild) {
|
||||||
containerRef.current.removeChild(containerRef.current.firstChild)
|
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current URL for redirect
|
// Get current URL for redirect
|
||||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
|
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
|
||||||
|
|
||||||
// Create script element for Telegram Login Widget
|
// Create script element for Telegram Login Widget
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script');
|
||||||
script.src = 'https://telegram.org/js/telegram-widget.js?22'
|
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
||||||
script.setAttribute('data-telegram-login', botUsername)
|
script.setAttribute('data-telegram-login', botUsername);
|
||||||
script.setAttribute('data-size', 'large')
|
script.setAttribute('data-size', 'large');
|
||||||
script.setAttribute('data-radius', '8')
|
script.setAttribute('data-radius', '8');
|
||||||
script.setAttribute('data-auth-url', redirectUrl)
|
script.setAttribute('data-auth-url', redirectUrl);
|
||||||
script.setAttribute('data-request-access', 'write')
|
script.setAttribute('data-request-access', 'write');
|
||||||
script.async = true
|
script.async = true;
|
||||||
|
|
||||||
containerRef.current.appendChild(script)
|
containerRef.current.appendChild(script);
|
||||||
}, [botUsername])
|
}, [botUsername]);
|
||||||
|
|
||||||
if (!botUsername || botUsername === 'your_bot') {
|
if (!botUsername || botUsername === 'your_bot') {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-gray-500 text-sm py-4">
|
<div className="py-4 text-center text-sm text-gray-500">
|
||||||
{t('auth.telegramNotConfigured')}
|
{t('auth.telegramNotConfigured')}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,19 +49,19 @@ export default function TelegramLoginButton({
|
|||||||
|
|
||||||
{/* Fallback link for mobile */}
|
{/* Fallback link for mobile */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p>
|
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
|
||||||
<a
|
<a
|
||||||
href={`https://t.me/${botUsername}`}
|
href={`https://t.me/${botUsername}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center text-sm text-telegram-blue hover:underline"
|
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
</svg>
|
</svg>
|
||||||
@{botUsername}
|
@{botUsername}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +1,122 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'
|
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
|
||||||
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'
|
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
|
||||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'
|
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
|
||||||
import { applyThemeColors } from '../hooks/useThemeColors'
|
import { applyThemeColors } from '../hooks/useThemeColors';
|
||||||
|
|
||||||
interface ThemeBentoPickerProps {
|
interface ThemeBentoPickerProps {
|
||||||
currentColors: ThemeColors
|
currentColors: ThemeColors;
|
||||||
onColorsChange: (colors: ThemeColors) => void
|
onColorsChange: (colors: ThemeColors) => void;
|
||||||
onSave: () => void
|
onSave: () => void;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChevronDownIcon = () => (
|
const ChevronDownIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const MoonIcon = () => (
|
const MoonIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SunIcon = () => (
|
const SunIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const StatusIcon = () => (
|
const StatusIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const PaletteIcon = () => (
|
const PaletteIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
function PresetCard({
|
function PresetCard({
|
||||||
preset,
|
preset,
|
||||||
isSelected,
|
isSelected,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
preset: ColorPreset
|
preset: ColorPreset;
|
||||||
isSelected: boolean
|
isSelected: boolean;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation();
|
||||||
const isRu = i18n.language === 'ru'
|
const isRu = i18n.language === 'ru';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${
|
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
|
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
|
||||||
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
|
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0"
|
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
|
||||||
style={{ backgroundColor: preset.preview.background }}
|
style={{ backgroundColor: preset.preview.background }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md"
|
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
|
||||||
style={{ backgroundColor: preset.preview.accent }}
|
style={{ backgroundColor: preset.preview.accent }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60"
|
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
|
||||||
style={{ backgroundColor: preset.preview.text }}
|
style={{ backgroundColor: preset.preview.text }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-5 right-2 w-7 h-1 rounded-full opacity-40"
|
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
|
||||||
style={{ backgroundColor: preset.preview.text }}
|
style={{ backgroundColor: preset.preview.text }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="text-xs font-semibold text-dark-100 truncate">
|
<h4 className="truncate text-xs font-semibold text-dark-100">
|
||||||
{isRu ? preset.nameRu : preset.name}
|
{isRu ? preset.nameRu : preset.name}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0">
|
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HSLSlider({
|
function HSLSlider({
|
||||||
@@ -111,18 +127,18 @@ function HSLSlider({
|
|||||||
gradient,
|
gradient,
|
||||||
suffix = '',
|
suffix = '',
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
value: number
|
value: number;
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void;
|
||||||
max: number
|
max: number;
|
||||||
gradient: string
|
gradient: string;
|
||||||
suffix?: string
|
suffix?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-medium text-dark-300">{label}</label>
|
<label className="text-xs font-medium text-dark-300">{label}</label>
|
||||||
<span className="text-xs text-dark-500 font-mono tabular-nums">
|
<span className="font-mono text-xs tabular-nums text-dark-500">
|
||||||
{value}
|
{value}
|
||||||
{suffix}
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
@@ -133,11 +149,11 @@ function HSLSlider({
|
|||||||
max={max}
|
max={max}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(parseInt(e.target.value))}
|
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||||
className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
|
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
|
||||||
style={{ background: gradient }}
|
style={{ background: gradient }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactColorInput({
|
function CompactColorInput({
|
||||||
@@ -145,45 +161,45 @@ function CompactColorInput({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string;
|
||||||
value: string
|
value: string;
|
||||||
onChange: (color: string) => void
|
onChange: (color: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [localValue, setLocalValue] = useState(value)
|
const [localValue, setLocalValue] = useState(value);
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalValue(value)
|
setLocalValue(value);
|
||||||
}, [value])
|
}, [value]);
|
||||||
|
|
||||||
const handleChange = (newValue: string) => {
|
const handleChange = (newValue: string) => {
|
||||||
let formatted = newValue.toUpperCase()
|
let formatted = newValue.toUpperCase();
|
||||||
if (!formatted.startsWith('#')) {
|
if (!formatted.startsWith('#')) {
|
||||||
formatted = '#' + formatted
|
formatted = '#' + formatted;
|
||||||
}
|
}
|
||||||
setLocalValue(formatted)
|
setLocalValue(formatted);
|
||||||
if (isValidHex(formatted)) {
|
if (isValidHex(formatted)) {
|
||||||
onChange(formatted)
|
onChange(formatted);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setIsEditing(false)
|
setIsEditing(false);
|
||||||
if (!isValidHex(localValue)) {
|
if (!isValidHex(localValue)) {
|
||||||
setLocalValue(value)
|
setLocalValue(value);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group">
|
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105"
|
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
|
||||||
style={{ backgroundColor: value }}
|
style={{ backgroundColor: value }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
|
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -194,21 +210,21 @@ function CompactColorInput({
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none"
|
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(true)}
|
onClick={() => setIsEditing(true)}
|
||||||
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left"
|
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
|
||||||
>
|
>
|
||||||
{value.toUpperCase()}
|
{value.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleSection({
|
function CollapsibleSection({
|
||||||
@@ -219,24 +235,24 @@ function CollapsibleSection({
|
|||||||
children,
|
children,
|
||||||
badge,
|
badge,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string;
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode;
|
||||||
isOpen: boolean
|
isOpen: boolean;
|
||||||
onToggle: () => void
|
onToggle: () => void;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
badge?: string
|
badge?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden">
|
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors"
|
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="text-dark-400">{icon}</div>
|
<div className="text-dark-400">{icon}</div>
|
||||||
<span className="text-sm font-medium text-dark-200">{title}</span>
|
<span className="text-sm font-medium text-dark-200">{title}</span>
|
||||||
{badge && (
|
{badge && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono">
|
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -254,11 +270,11 @@ function CollapsibleSection({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="px-4 pb-4 pt-1 border-t border-dark-700/30">{children}</div>
|
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeBentoPicker({
|
export function ThemeBentoPicker({
|
||||||
@@ -267,67 +283,67 @@ export function ThemeBentoPicker({
|
|||||||
onSave,
|
onSave,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: ThemeBentoPickerProps) {
|
}: ThemeBentoPickerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent))
|
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
|
||||||
const [hexInput, setHexInput] = useState(currentColors.accent)
|
const [hexInput, setHexInput] = useState(currentColors.accent);
|
||||||
const [hasChanges, setHasChanges] = useState(false)
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
const [isPresetsOpen, setIsPresetsOpen] = useState(false)
|
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
|
||||||
const [isAccentOpen, setIsAccentOpen] = useState(false)
|
const [isAccentOpen, setIsAccentOpen] = useState(false);
|
||||||
const [isDarkOpen, setIsDarkOpen] = useState(false)
|
const [isDarkOpen, setIsDarkOpen] = useState(false);
|
||||||
const [isLightOpen, setIsLightOpen] = useState(false)
|
const [isLightOpen, setIsLightOpen] = useState(false);
|
||||||
const [isStatusOpen, setIsStatusOpen] = useState(false)
|
const [isStatusOpen, setIsStatusOpen] = useState(false);
|
||||||
|
|
||||||
const selectedPresetId = useMemo(() => {
|
const selectedPresetId = useMemo(() => {
|
||||||
const match = COLOR_PRESETS.find(
|
const match = COLOR_PRESETS.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
|
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
|
||||||
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
|
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
|
||||||
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase()
|
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
|
||||||
)
|
);
|
||||||
return match?.id ?? null
|
return match?.id ?? null;
|
||||||
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground])
|
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHsl(hexToHsl(currentColors.accent))
|
setHsl(hexToHsl(currentColors.accent));
|
||||||
setHexInput(currentColors.accent)
|
setHexInput(currentColors.accent);
|
||||||
}, [currentColors.accent])
|
}, [currentColors.accent]);
|
||||||
|
|
||||||
const updateColor = useCallback(
|
const updateColor = useCallback(
|
||||||
(key: keyof ThemeColors, value: string) => {
|
(key: keyof ThemeColors, value: string) => {
|
||||||
const newColors = { ...currentColors, [key]: value }
|
const newColors = { ...currentColors, [key]: value };
|
||||||
onColorsChange(newColors)
|
onColorsChange(newColors);
|
||||||
applyThemeColors(newColors)
|
applyThemeColors(newColors);
|
||||||
setHasChanges(true)
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[currentColors, onColorsChange]
|
[currentColors, onColorsChange],
|
||||||
)
|
);
|
||||||
|
|
||||||
const updateAccentFromHsl = useCallback(
|
const updateAccentFromHsl = useCallback(
|
||||||
(newHsl: HSLColor) => {
|
(newHsl: HSLColor) => {
|
||||||
setHsl(newHsl)
|
setHsl(newHsl);
|
||||||
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l)
|
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
|
||||||
setHexInput(newHex)
|
setHexInput(newHex);
|
||||||
updateColor('accent', newHex)
|
updateColor('accent', newHex);
|
||||||
},
|
},
|
||||||
[updateColor]
|
[updateColor],
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleHexInputChange = (value: string) => {
|
const handleHexInputChange = (value: string) => {
|
||||||
setHexInput(value)
|
setHexInput(value);
|
||||||
if (isValidHex(value)) {
|
if (isValidHex(value)) {
|
||||||
const newHsl = hexToHsl(value)
|
const newHsl = hexToHsl(value);
|
||||||
setHsl(newHsl)
|
setHsl(newHsl);
|
||||||
updateColor('accent', value)
|
updateColor('accent', value);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePresetSelect = (preset: ColorPreset) => {
|
const handlePresetSelect = (preset: ColorPreset) => {
|
||||||
onColorsChange(preset.colors)
|
onColorsChange(preset.colors);
|
||||||
applyThemeColors(preset.colors)
|
applyThemeColors(preset.colors);
|
||||||
setHasChanges(true)
|
setHasChanges(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const hueGradient = useMemo(() => {
|
const hueGradient = useMemo(() => {
|
||||||
return `linear-gradient(to right,
|
return `linear-gradient(to right,
|
||||||
@@ -338,23 +354,23 @@ export function ThemeBentoPicker({
|
|||||||
hsl(240, ${hsl.s}%, ${hsl.l}%),
|
hsl(240, ${hsl.s}%, ${hsl.l}%),
|
||||||
hsl(300, ${hsl.s}%, ${hsl.l}%),
|
hsl(300, ${hsl.s}%, ${hsl.l}%),
|
||||||
hsl(360, ${hsl.s}%, ${hsl.l}%)
|
hsl(360, ${hsl.s}%, ${hsl.l}%)
|
||||||
)`
|
)`;
|
||||||
}, [hsl.s, hsl.l])
|
}, [hsl.s, hsl.l]);
|
||||||
|
|
||||||
const saturationGradient = useMemo(() => {
|
const saturationGradient = useMemo(() => {
|
||||||
return `linear-gradient(to right,
|
return `linear-gradient(to right,
|
||||||
hsl(${hsl.h}, 0%, ${hsl.l}%),
|
hsl(${hsl.h}, 0%, ${hsl.l}%),
|
||||||
hsl(${hsl.h}, 100%, ${hsl.l}%)
|
hsl(${hsl.h}, 100%, ${hsl.l}%)
|
||||||
)`
|
)`;
|
||||||
}, [hsl.h, hsl.l])
|
}, [hsl.h, hsl.l]);
|
||||||
|
|
||||||
const lightnessGradient = useMemo(() => {
|
const lightnessGradient = useMemo(() => {
|
||||||
return `linear-gradient(to right,
|
return `linear-gradient(to right,
|
||||||
hsl(${hsl.h}, ${hsl.s}%, 0%),
|
hsl(${hsl.h}, ${hsl.s}%, 0%),
|
||||||
hsl(${hsl.h}, ${hsl.s}%, 50%),
|
hsl(${hsl.h}, ${hsl.s}%, 50%),
|
||||||
hsl(${hsl.h}, ${hsl.s}%, 100%)
|
hsl(${hsl.h}, ${hsl.s}%, 100%)
|
||||||
)`
|
)`;
|
||||||
}, [hsl.h, hsl.s])
|
}, [hsl.h, hsl.s]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -365,9 +381,13 @@ export function ThemeBentoPicker({
|
|||||||
isOpen={isPresetsOpen}
|
isOpen={isPresetsOpen}
|
||||||
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
|
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-1">
|
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
|
||||||
{COLOR_PRESETS.map((preset, index) => (
|
{COLOR_PRESETS.map((preset, index) => (
|
||||||
<div key={preset.id} className="min-h-[100px]" style={{ '--stagger': index } as React.CSSProperties}>
|
<div
|
||||||
|
key={preset.id}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
style={{ '--stagger': index } as React.CSSProperties}
|
||||||
|
>
|
||||||
<PresetCard
|
<PresetCard
|
||||||
preset={preset}
|
preset={preset}
|
||||||
isSelected={selectedPresetId === preset.id}
|
isSelected={selectedPresetId === preset.id}
|
||||||
@@ -379,7 +399,7 @@ export function ThemeBentoPicker({
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide">
|
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||||
{t('admin.theme.customizeColors', 'Customize Colors')}
|
{t('admin.theme.customizeColors', 'Customize Colors')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -392,13 +412,13 @@ export function ThemeBentoPicker({
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<div
|
||||||
className="w-full h-14 rounded-xl shadow-inner relative overflow-hidden"
|
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
|
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
||||||
<div className="absolute bottom-2 right-3 text-white/80 text-xs font-mono drop-shadow">
|
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
|
||||||
{hexInput.toUpperCase()}
|
{hexInput.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,7 +451,7 @@ export function ThemeBentoPicker({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-dark-300 mb-1.5 block">
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
{t('admin.theme.hexCode', 'HEX Code')}
|
{t('admin.theme.hexCode', 'HEX Code')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -440,7 +460,7 @@ export function ThemeBentoPicker({
|
|||||||
onChange={(e) => handleHexInputChange(e.target.value)}
|
onChange={(e) => handleHexInputChange(e.target.value)}
|
||||||
placeholder="#3b82f6"
|
placeholder="#3b82f6"
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
className="input w-full text-sm font-mono uppercase"
|
className="input w-full font-mono text-sm uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,8 +552,8 @@ export function ThemeBentoPicker({
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4">
|
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
|
||||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
|
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||||
{t('theme.preview', 'Preview')}
|
{t('theme.preview', 'Preview')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -548,12 +568,12 @@ export function ThemeBentoPicker({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="flex justify-end animate-fade-in">
|
<div className="flex animate-fade-in justify-end">
|
||||||
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
||||||
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,55 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ticketNotificationsApi } from '../api/ticketNotifications'
|
import { ticketNotificationsApi } from '../api/ticketNotifications';
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth';
|
||||||
import { useToast } from './Toast'
|
import { useToast } from './Toast';
|
||||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
|
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
import type { TicketNotification } from '../types'
|
import type { TicketNotification } from '../types';
|
||||||
|
|
||||||
const BellIcon = () => (
|
const BellIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
interface TicketNotificationBellProps {
|
interface TicketNotificationBellProps {
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
|
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore();
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast();
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||||
|
|
||||||
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
|
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
|
||||||
const dropdownTop = isFullscreen
|
const dropdownTop = isFullscreen
|
||||||
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
|
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
|
||||||
: 64 // default header height
|
: 64; // default header height
|
||||||
|
|
||||||
// Show toast for WebSocket notification
|
// Show toast for WebSocket notification
|
||||||
const showWSNotificationToast = useCallback((message: WSMessage) => {
|
const showWSNotificationToast = useCallback(
|
||||||
const isNewTicket = message.type === 'ticket.new'
|
(message: WSMessage) => {
|
||||||
const isAdminReply = message.type === 'ticket.admin_reply'
|
const isNewTicket = message.type === 'ticket.new';
|
||||||
const isUserReply = message.type === 'ticket.user_reply'
|
const isAdminReply = message.type === 'ticket.admin_reply';
|
||||||
|
const isUserReply = message.type === 'ticket.user_reply';
|
||||||
|
|
||||||
const icon = isNewTicket ? (
|
const icon = isNewTicket ? (
|
||||||
<span className="text-lg">🎫</span>
|
<span className="text-lg">🎫</span>
|
||||||
@@ -52,22 +57,30 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
<span className="text-lg">💬</span>
|
<span className="text-lg">💬</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-lg">📨</span>
|
<span className="text-lg">📨</span>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ticketTitle = message.title || ''
|
const ticketTitle = message.title || '';
|
||||||
|
|
||||||
let toastTitle: string
|
let toastTitle: string;
|
||||||
let toastMessage: string
|
let toastMessage: string;
|
||||||
|
|
||||||
if (isNewTicket) {
|
if (isNewTicket) {
|
||||||
toastTitle = t('notifications.newTicketTitle', 'New Ticket')
|
toastTitle = t('notifications.newTicketTitle', 'New Ticket');
|
||||||
toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle })
|
toastMessage =
|
||||||
|
message.message ||
|
||||||
|
t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle });
|
||||||
} else if (isUserReply) {
|
} else if (isUserReply) {
|
||||||
toastTitle = t('notifications.newUserReplyTitle', 'User Reply')
|
toastTitle = t('notifications.newUserReplyTitle', 'User Reply');
|
||||||
toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle })
|
toastMessage =
|
||||||
|
message.message ||
|
||||||
|
t('notifications.newUserReply', 'User replied in ticket: {{title}}', {
|
||||||
|
title: ticketTitle,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
toastTitle = t('notifications.newReplyTitle', 'New Reply')
|
toastTitle = t('notifications.newReplyTitle', 'New Reply');
|
||||||
toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle })
|
toastMessage =
|
||||||
|
message.message ||
|
||||||
|
t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle });
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
@@ -76,134 +89,164 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
message: toastMessage,
|
message: toastMessage,
|
||||||
icon,
|
icon,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`)
|
navigate(
|
||||||
|
isAdmin
|
||||||
|
? `/admin/tickets?ticket=${message.ticket_id}`
|
||||||
|
: `/support?ticket=${message.ticket_id}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
})
|
});
|
||||||
}, [showToast, navigate, isAdmin, t])
|
},
|
||||||
|
[showToast, navigate, isAdmin, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle WebSocket message
|
// Handle WebSocket message
|
||||||
const handleWSMessage = useCallback((message: WSMessage) => {
|
const handleWSMessage = useCallback(
|
||||||
|
(message: WSMessage) => {
|
||||||
// Check if this notification is relevant for this user type
|
// Check if this notification is relevant for this user type
|
||||||
const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply'
|
const isAdminNotification =
|
||||||
const isUserNotification = message.type === 'ticket.admin_reply'
|
message.type === 'ticket.new' || message.type === 'ticket.user_reply';
|
||||||
|
const isUserNotification = message.type === 'ticket.admin_reply';
|
||||||
|
|
||||||
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
|
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
|
||||||
// Show toast
|
// Show toast
|
||||||
showWSNotificationToast(message)
|
showWSNotificationToast(message);
|
||||||
|
|
||||||
// Invalidate queries to refresh count and list
|
// Invalidate queries to refresh count and list
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count']
|
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||||
})
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications']
|
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [isAdmin, showWSNotificationToast, queryClient])
|
},
|
||||||
|
[isAdmin, showWSNotificationToast, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
// WebSocket connection
|
// WebSocket connection
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
onMessage: handleWSMessage,
|
onMessage: handleWSMessage,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch unread count (with slower polling as fallback when WS disconnects)
|
// Fetch unread count (with slower polling as fallback when WS disconnects)
|
||||||
const { data: unreadData } = useQuery({
|
const { data: unreadData } = useQuery({
|
||||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||||
queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount,
|
queryFn: isAdmin
|
||||||
|
? ticketNotificationsApi.getAdminUnreadCount
|
||||||
|
: ticketNotificationsApi.getUnreadCount,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
refetchInterval: 60000, // Poll every 60 seconds as fallback
|
refetchInterval: 60000, // Poll every 60 seconds as fallback
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch notifications when dropdown is open
|
// Fetch notifications when dropdown is open
|
||||||
const { data: notificationsData, isLoading } = useQuery({
|
const { data: notificationsData, isLoading } = useQuery({
|
||||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||||
queryFn: () => isAdmin
|
queryFn: () =>
|
||||||
|
isAdmin
|
||||||
? ticketNotificationsApi.getAdminNotifications(false, 10)
|
? ticketNotificationsApi.getAdminNotifications(false, 10)
|
||||||
: ticketNotificationsApi.getNotifications(false, 10),
|
: ticketNotificationsApi.getNotifications(false, 10),
|
||||||
enabled: isAuthenticated && isOpen,
|
enabled: isAuthenticated && isOpen,
|
||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mark all as read mutation
|
// Mark all as read mutation
|
||||||
const markAllReadMutation = useMutation({
|
const markAllReadMutation = useMutation({
|
||||||
mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead,
|
mutationFn: isAdmin
|
||||||
|
? ticketNotificationsApi.markAllAdminAsRead
|
||||||
|
: ticketNotificationsApi.markAllAsRead,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
|
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mark single as read mutation
|
// Mark single as read mutation
|
||||||
const markReadMutation = useMutation({
|
const markReadMutation = useMutation({
|
||||||
mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead,
|
mutationFn: isAdmin
|
||||||
|
? ticketNotificationsApi.markAdminAsRead
|
||||||
|
: ticketNotificationsApi.markAsRead,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
|
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleNotificationClick = (notification: TicketNotification) => {
|
const handleNotificationClick = (notification: TicketNotification) => {
|
||||||
if (!notification.is_read) {
|
if (!notification.is_read) {
|
||||||
markReadMutation.mutate(notification.id)
|
markReadMutation.mutate(notification.id);
|
||||||
}
|
|
||||||
setIsOpen(false)
|
|
||||||
navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`)
|
|
||||||
}
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
navigate(
|
||||||
|
isAdmin
|
||||||
|
? `/admin/tickets?ticket=${notification.ticket_id}`
|
||||||
|
: `/support?ticket=${notification.ticket_id}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTime = (dateStr: string) => {
|
const formatTime = (dateStr: string) => {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr);
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime()
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffMins = Math.floor(diffMs / 60000)
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
const diffHours = Math.floor(diffMins / 60)
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
const diffDays = Math.floor(diffHours / 24)
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
if (diffMins < 1) return t('notifications.justNow', 'Just now')
|
if (diffMins < 1) return t('notifications.justNow', 'Just now');
|
||||||
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins })
|
if (diffMins < 60)
|
||||||
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours })
|
return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins });
|
||||||
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays })
|
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours });
|
||||||
}
|
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays });
|
||||||
|
};
|
||||||
|
|
||||||
const getNotificationIcon = (type: string) => {
|
const getNotificationIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'new_ticket':
|
case 'new_ticket':
|
||||||
return <span className="text-lg">🎫</span>
|
return <span className="text-lg">🎫</span>;
|
||||||
case 'admin_reply':
|
case 'admin_reply':
|
||||||
return <span className="text-lg">💬</span>
|
return <span className="text-lg">💬</span>;
|
||||||
case 'user_reply':
|
case 'user_reply':
|
||||||
return <span className="text-lg">📨</span>
|
return <span className="text-lg">📨</span>;
|
||||||
default:
|
default:
|
||||||
return <span className="text-lg">🔔</span>
|
return <span className="text-lg">🔔</span>;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unreadCount = unreadData?.unread_count || 0
|
const unreadCount = unreadData?.unread_count || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
{/* Bell button */}
|
{/* Bell button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="relative p-2 rounded-xl transition-all duration-200 bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50 text-dark-400 hover:text-accent-400"
|
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
|
||||||
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
||||||
>
|
>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full px-1 animate-scale-in-bounce">
|
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] animate-scale-in-bounce items-center justify-center rounded-full bg-error-500 px-1 text-xs font-bold text-white">
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -212,13 +255,13 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={`fixed sm:absolute sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in ${
|
className={`fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96 ${
|
||||||
!isFullscreen ? 'top-16' : ''
|
!isFullscreen ? 'top-16' : ''
|
||||||
}`}
|
}`}
|
||||||
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
|
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30">
|
<div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||||
<h3 className="text-sm font-semibold text-dark-100">
|
<h3 className="text-sm font-semibold text-dark-100">
|
||||||
{t('notifications.ticketNotifications', 'Ticket Notifications')}
|
{t('notifications.ticketNotifications', 'Ticket Notifications')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -226,7 +269,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
<button
|
<button
|
||||||
onClick={() => markAllReadMutation.mutate()}
|
onClick={() => markAllReadMutation.mutate()}
|
||||||
disabled={markAllReadMutation.isPending}
|
disabled={markAllReadMutation.isPending}
|
||||||
className="flex items-center gap-1.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors"
|
className="flex items-center gap-1.5 text-xs text-accent-400 transition-colors hover:text-accent-300 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
{t('notifications.markAllRead', 'Mark all read')}
|
{t('notifications.markAllRead', 'Mark all read')}
|
||||||
@@ -238,32 +281,34 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-8 text-center text-dark-500">
|
<div className="p-8 text-center text-dark-500">
|
||||||
<div className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div>
|
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
) : notificationsData?.items && notificationsData.items.length > 0 ? (
|
) : notificationsData?.items && notificationsData.items.length > 0 ? (
|
||||||
notificationsData.items.map((notification: TicketNotification) => (
|
notificationsData.items.map((notification: TicketNotification) => (
|
||||||
<button
|
<button
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
onClick={() => handleNotificationClick(notification)}
|
onClick={() => handleNotificationClick(notification)}
|
||||||
className={`w-full text-left px-4 py-3 border-b border-dark-800/50 last:border-b-0 hover:bg-dark-800/50 transition-all duration-200 ${
|
className={`w-full border-b border-dark-800/50 px-4 py-3 text-left transition-all duration-200 last:border-b-0 hover:bg-dark-800/50 ${
|
||||||
!notification.is_read ? 'bg-accent-500/5' : ''
|
!notification.is_read ? 'bg-accent-500/5' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-800/50">
|
||||||
{getNotificationIcon(notification.notification_type)}
|
{getNotificationIcon(notification.notification_type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
|
<p
|
||||||
|
className={`text-sm leading-relaxed ${!notification.is_read ? 'font-medium text-dark-100' : 'text-dark-300'}`}
|
||||||
|
>
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-dark-500 mt-1">
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
{formatTime(notification.created_at)}
|
{formatTime(notification.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!notification.is_read && (
|
{!notification.is_read && (
|
||||||
<div className="flex-shrink-0 pt-1">
|
<div className="flex-shrink-0 pt-1">
|
||||||
<span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span>
|
<span className="block h-2.5 w-2.5 rounded-full bg-accent-500 shadow-lg shadow-accent-500/50"></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,23 +316,25 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="w-12 h-12 rounded-2xl bg-dark-800/50 flex items-center justify-center mx-auto mb-3 text-dark-500">
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-dark-800/50 text-dark-500">
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p>
|
<p className="text-sm text-dark-500">
|
||||||
|
{t('notifications.noNotifications', 'No notifications')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{notificationsData?.items && notificationsData.items.length > 0 && (
|
{notificationsData?.items && notificationsData.items.length > 0 && (
|
||||||
<div className="px-4 py-3 border-t border-dark-700/50 bg-dark-800/30">
|
<div className="border-t border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
navigate(isAdmin ? '/admin/tickets' : '/support')
|
navigate(isAdmin ? '/admin/tickets' : '/support');
|
||||||
}}
|
}}
|
||||||
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1 transition-colors"
|
className="w-full py-1 text-center text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
>
|
>
|
||||||
{t('notifications.viewAll', 'View all tickets')}
|
{t('notifications.viewAll', 'View all tickets')}
|
||||||
</button>
|
</button>
|
||||||
@@ -296,5 +343,5 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,99 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
interface ToastOptions {
|
interface ToastOptions {
|
||||||
type?: 'success' | 'error' | 'info' | 'warning'
|
type?: 'success' | 'error' | 'info' | 'warning';
|
||||||
message: string
|
message: string;
|
||||||
title?: string
|
title?: string;
|
||||||
icon?: ReactNode
|
icon?: ReactNode;
|
||||||
duration?: number
|
duration?: number;
|
||||||
onClick?: () => void
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Toast extends ToastOptions {
|
interface Toast extends ToastOptions {
|
||||||
id: number
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastContextType {
|
interface ToastContextType {
|
||||||
showToast: (options: ToastOptions) => void
|
showToast: (options: ToastOptions) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextType | null>(null)
|
const ToastContext = createContext<ToastContextType | null>(null);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function useToast() {
|
export function useToast() {
|
||||||
const context = useContext(ToastContext)
|
const context = useContext(ToastContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useToast must be used within ToastProvider')
|
throw new Error('useToast must be used within ToastProvider');
|
||||||
}
|
}
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
const showToast = useCallback((options: ToastOptions) => {
|
const showToast = useCallback((options: ToastOptions) => {
|
||||||
const id = Date.now() + Math.random() // Avoid ID collision
|
const id = Date.now() + Math.random(); // Avoid ID collision
|
||||||
const toast: Toast = { id, duration: 5000, type: 'info', ...options }
|
const toast: Toast = { id, duration: 5000, type: 'info', ...options };
|
||||||
|
|
||||||
setToasts(prev => [...prev, toast])
|
setToasts((prev) => [...prev, toast]);
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
timersRef.current.delete(id)
|
timersRef.current.delete(id);
|
||||||
}, toast.duration)
|
}, toast.duration);
|
||||||
|
|
||||||
timersRef.current.set(id, timer)
|
timersRef.current.set(id, timer);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const removeToast = useCallback((id: number) => {
|
const removeToast = useCallback((id: number) => {
|
||||||
// Clear timer when manually removing
|
// Clear timer when manually removing
|
||||||
const timer = timersRef.current.get(id)
|
const timer = timersRef.current.get(id);
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
timersRef.current.delete(id)
|
timersRef.current.delete(id);
|
||||||
}
|
}
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Cleanup all timers on unmount
|
// Cleanup all timers on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timers = timersRef.current
|
const timers = timersRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
timers.forEach(timer => clearTimeout(timer))
|
timers.forEach((timer) => clearTimeout(timer));
|
||||||
timers.clear()
|
timers.clear();
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ showToast }}>
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* Toast Container */}
|
{/* Toast Container */}
|
||||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
|
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3">
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<ToastItem
|
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||||
key={toast.id}
|
|
||||||
toast={toast}
|
|
||||||
onClose={() => removeToast(toast.id)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ToastContext.Provider>
|
</ToastContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (toast.onClick) {
|
if (toast.onClick) {
|
||||||
toast.onClick()
|
toast.onClick();
|
||||||
onClose()
|
onClose();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const typeStyles = {
|
const typeStyles = {
|
||||||
success: {
|
success: {
|
||||||
@@ -115,81 +120,105 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|||||||
icon: 'text-accent-400',
|
icon: 'text-accent-400',
|
||||||
iconBg: 'bg-accent-500/20',
|
iconBg: 'bg-accent-500/20',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const style = typeStyles[toast.type || 'info']
|
const style = typeStyles[toast.type || 'info'];
|
||||||
|
|
||||||
const defaultIcons = {
|
const defaultIcons = {
|
||||||
success: (
|
success: (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
error: (
|
error: (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
warning: (
|
warning: (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
info: (
|
info: (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border backdrop-blur-xl ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/20 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
|
||||||
pointer-events-auto
|
|
||||||
w-80 sm:w-96
|
|
||||||
${style.bg}
|
|
||||||
backdrop-blur-xl
|
|
||||||
border ${style.border}
|
|
||||||
rounded-2xl
|
|
||||||
shadow-2xl shadow-black/20
|
|
||||||
overflow-hidden
|
|
||||||
animate-slide-in-right
|
|
||||||
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
|
|
||||||
transition-transform duration-200
|
|
||||||
`}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{/* Glow effect */}
|
{/* Glow effect */}
|
||||||
<div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} />
|
<div className={`absolute inset-0 ${style.bg} opacity-50 blur-xl`} />
|
||||||
|
|
||||||
<div className="relative p-4">
|
<div className="relative p-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}>
|
<div
|
||||||
|
className={`h-10 w-10 flex-shrink-0 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}
|
||||||
|
>
|
||||||
{toast.icon || defaultIcons[toast.type || 'info']}
|
{toast.icon || defaultIcons[toast.type || 'info']}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0 pt-0.5">
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
{toast.title && (
|
{toast.title && (
|
||||||
<p className="text-sm font-semibold text-dark-100 mb-0.5">
|
<p className="mb-0.5 text-sm font-semibold text-dark-100">{toast.title}</p>
|
||||||
{toast.title}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-dark-300 leading-relaxed">
|
<p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
|
||||||
{toast.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
onClose()
|
onClose();
|
||||||
}}
|
}}
|
||||||
className="flex-shrink-0 w-6 h-6 rounded-lg hover:bg-dark-700/50 flex items-center justify-center text-dark-500 hover:text-dark-300 transition-colors"
|
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -206,5 +235,5 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,285 +1,345 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { balanceApi } from '../api/balance'
|
import { balanceApi } from '../api/balance';
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||||
import type { PaymentMethod } from '../types'
|
import type { PaymentMethod } from '../types';
|
||||||
import BentoCard from './ui/BentoCard'
|
import BentoCard from './ui/BentoCard';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const WalletIcon = () => (
|
const WalletIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const StarIcon = () => (
|
const StarIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CardIcon = () => (
|
const CardIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CryptoIcon = () => (
|
const CryptoIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SparklesIcon = () => (
|
const SparklesIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ExternalLinkIcon = () => (
|
const ExternalLinkIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CopyIcon = () => (
|
const CopyIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
method: PaymentMethod
|
method: PaymentMethod;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
initialAmountRubles?: number
|
initialAmountRubles?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIsMobile() {
|
function useIsMobile() {
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false;
|
||||||
return window.innerWidth < 640
|
return window.innerWidth < 640;
|
||||||
})
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const check = () => setIsMobile(window.innerWidth < 640)
|
const check = () => setIsMobile(window.innerWidth < 640);
|
||||||
window.addEventListener('resize', check)
|
window.addEventListener('resize', check);
|
||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check);
|
||||||
}, [])
|
}, []);
|
||||||
return isMobile
|
return isMobile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get method icon based on method type
|
// Get method icon based on method type
|
||||||
const getMethodIcon = (methodId: string) => {
|
const getMethodIcon = (methodId: string) => {
|
||||||
const id = methodId.toLowerCase()
|
const id = methodId.toLowerCase();
|
||||||
if (id.includes('stars')) return <StarIcon />
|
if (id.includes('stars')) return <StarIcon />;
|
||||||
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />
|
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />;
|
||||||
return <CardIcon />
|
return <CardIcon />;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
|
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
|
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
|
useCurrency();
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
|
||||||
const isMobileScreen = useIsMobile()
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isMobileScreen = useIsMobile();
|
||||||
|
|
||||||
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
|
const safeBottom = isTelegramWebApp
|
||||||
|
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const getInitialAmount = (): string => {
|
const getInitialAmount = (): string => {
|
||||||
if (!initialAmountRubles || initialAmountRubles <= 0) return ''
|
if (!initialAmountRubles || initialAmountRubles <= 0) return '';
|
||||||
const converted = convertAmount(initialAmountRubles)
|
const converted = convertAmount(initialAmountRubles);
|
||||||
return (targetCurrency === 'IRR' || targetCurrency === 'RUB')
|
return targetCurrency === 'IRR' || targetCurrency === 'RUB'
|
||||||
? Math.ceil(converted).toString()
|
? Math.ceil(converted).toString()
|
||||||
: converted.toFixed(2)
|
: converted.toFixed(2);
|
||||||
}
|
};
|
||||||
|
|
||||||
const [amount, setAmount] = useState(getInitialAmount)
|
const [amount, setAmount] = useState(getInitialAmount);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
method.options && method.options.length > 0 ? method.options[0].id : null,
|
||||||
)
|
);
|
||||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
|
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false);
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false)
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
onClose()
|
onClose();
|
||||||
}, [onClose])
|
}, [onClose]);
|
||||||
|
|
||||||
// Keyboard: Escape to close (PC)
|
// Keyboard: Escape to close (PC)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
handleClose()
|
handleClose();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [handleClose])
|
}, [handleClose]);
|
||||||
|
|
||||||
// Telegram back button (Android)
|
// Telegram back button (Android)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webApp?.BackButton) return
|
if (!webApp?.BackButton) return;
|
||||||
webApp.BackButton.show()
|
webApp.BackButton.show();
|
||||||
webApp.BackButton.onClick(handleClose)
|
webApp.BackButton.onClick(handleClose);
|
||||||
return () => {
|
return () => {
|
||||||
webApp.BackButton.offClick(handleClose)
|
webApp.BackButton.offClick(handleClose);
|
||||||
webApp.BackButton.hide()
|
webApp.BackButton.hide();
|
||||||
}
|
};
|
||||||
}, [webApp, handleClose])
|
}, [webApp, handleClose]);
|
||||||
|
|
||||||
// Scroll lock
|
// Scroll lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollY = window.scrollY
|
const scrollY = window.scrollY;
|
||||||
const preventScroll = (e: TouchEvent) => {
|
const preventScroll = (e: TouchEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('[data-modal-content]')) return
|
if (target.closest('[data-modal-content]')) return;
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
}
|
};
|
||||||
const preventWheel = (e: WheelEvent) => {
|
const preventWheel = (e: WheelEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('[data-modal-content]')) return
|
if (target.closest('[data-modal-content]')) return;
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
}
|
};
|
||||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
document.addEventListener('wheel', preventWheel, { passive: false });
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden';
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('touchmove', preventScroll)
|
document.removeEventListener('touchmove', preventScroll);
|
||||||
document.removeEventListener('wheel', preventWheel)
|
document.removeEventListener('wheel', preventWheel);
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = '';
|
||||||
window.scrollTo(0, scrollY)
|
window.scrollTo(0, scrollY);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const hasOptions = method.options && method.options.length > 0
|
const hasOptions = method.options && method.options.length > 0;
|
||||||
const minRubles = method.min_amount_kopeks / 100
|
const minRubles = method.min_amount_kopeks / 100;
|
||||||
const maxRubles = method.max_amount_kopeks / 100
|
const maxRubles = method.max_amount_kopeks / 100;
|
||||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||||
const isStarsMethod = methodKey.includes('stars')
|
const isStarsMethod = methodKey.includes('stars');
|
||||||
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
|
const methodName =
|
||||||
|
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
|
||||||
|
|
||||||
const starsPaymentMutation = useMutation({
|
const starsPaymentMutation = useMutation({
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const webApp = window.Telegram?.WebApp
|
const webApp = window.Telegram?.WebApp;
|
||||||
if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return }
|
if (!data.invoice_url) {
|
||||||
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
|
setError('Сервер не вернул ссылку на оплату');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!webApp?.openInvoice) {
|
||||||
|
setError('Оплата Stars доступна только в Telegram Mini App');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
webApp.openInvoice(data.invoice_url, (status) => {
|
webApp.openInvoice(data.invoice_url, (status) => {
|
||||||
if (status === 'paid') { setError(null); onClose() }
|
if (status === 'paid') {
|
||||||
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
|
setError(null);
|
||||||
})
|
onClose();
|
||||||
} catch (e) { setError('Ошибка: ' + String(e)) }
|
} else if (status === 'failed') {
|
||||||
|
setError(t('wheel.starsPaymentFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError('Ошибка: ' + String(e));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const axiosError = err as { response?: { data?: { detail?: string }, status?: number } }
|
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
|
||||||
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`)
|
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const topUpMutation = useMutation<{
|
const topUpMutation = useMutation<
|
||||||
payment_id: string; payment_url?: string; invoice_url?: string
|
{
|
||||||
amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null
|
payment_id: string;
|
||||||
}, unknown, number>({
|
payment_url?: string;
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
invoice_url?: string;
|
||||||
|
amount_kopeks: number;
|
||||||
|
amount_rubles: number;
|
||||||
|
status: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
},
|
||||||
|
unknown,
|
||||||
|
number
|
||||||
|
>({
|
||||||
|
mutationFn: (amountKopeks: number) =>
|
||||||
|
balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const redirectUrl = data.payment_url || (data as any).invoice_url
|
const redirectUrl = data.payment_url || data.invoice_url;
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
// Always show the payment link for user to click manually
|
// Always show the payment link for user to click manually
|
||||||
// This ensures it works on all platforms including iOS Safari
|
// This ensures it works on all platforms including iOS Safari
|
||||||
setPaymentUrl(redirectUrl)
|
setPaymentUrl(redirectUrl);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
|
const detail =
|
||||||
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error')))
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '';
|
||||||
|
setError(
|
||||||
|
detail.includes('not yet implemented') ? t('balance.useBot') : detail || t('common.error'),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setError(null)
|
setError(null);
|
||||||
setPaymentUrl(null)
|
setPaymentUrl(null);
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur();
|
||||||
|
|
||||||
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
||||||
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.')
|
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (hasOptions && !selectedOption) { setError('Выберите способ'); return }
|
if (hasOptions && !selectedOption) {
|
||||||
const amountCurrency = parseFloat(amount)
|
setError('Выберите способ');
|
||||||
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return }
|
return;
|
||||||
const amountRubles = convertToRub(amountCurrency)
|
}
|
||||||
|
const amountCurrency = parseFloat(amount);
|
||||||
|
if (isNaN(amountCurrency) || amountCurrency <= 0) {
|
||||||
|
setError('Введите сумму');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amountRubles = convertToRub(amountCurrency);
|
||||||
if (amountRubles < minRubles || amountRubles > maxRubles) {
|
if (amountRubles < minRubles || amountRubles > maxRubles) {
|
||||||
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); return
|
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const amountKopeks = Math.round(amountRubles * 100)
|
const amountKopeks = Math.round(amountRubles * 100);
|
||||||
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) }
|
if (isStarsMethod) {
|
||||||
else { topUpMutation.mutate(amountKopeks) }
|
starsPaymentMutation.mutate(amountKopeks);
|
||||||
|
} else {
|
||||||
|
topUpMutation.mutate(amountKopeks);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles)
|
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles);
|
||||||
const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2
|
const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2;
|
||||||
const getQuickValue = (rub: number) => (targetCurrency === 'IRR')
|
const getQuickValue = (rub: number) =>
|
||||||
|
targetCurrency === 'IRR'
|
||||||
? Math.round(convertAmount(rub)).toString()
|
? Math.round(convertAmount(rub)).toString()
|
||||||
: convertAmount(rub).toFixed(currencyDecimals)
|
: convertAmount(rub).toFixed(currencyDecimals);
|
||||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
|
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
||||||
|
|
||||||
const handleCopyUrl = async () => {
|
const handleCopyUrl = async () => {
|
||||||
if (!paymentUrl) return
|
if (!paymentUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(paymentUrl)
|
await navigator.clipboard.writeText(paymentUrl);
|
||||||
setCopied(true)
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to copy:', e)
|
console.warn('Failed to copy:', e);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
// Auto-focus input - works on mobile in Telegram WebApp
|
// Auto-focus input - works on mobile in Telegram WebApp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus()
|
inputRef.current.focus();
|
||||||
if (isMobileScreen) {
|
if (isMobileScreen) {
|
||||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100);
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer);
|
||||||
}, [])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Content JSX - shared between mobile and desktop
|
// Content JSX - shared between mobile and desktop
|
||||||
const contentJSX = (
|
const contentJSX = (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header icon and method */}
|
{/* Header icon and method */}
|
||||||
<div className="flex items-center gap-4 pb-1">
|
<div className="flex items-center gap-4 pb-1">
|
||||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${
|
<div
|
||||||
|
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
|
||||||
isStarsMethod
|
isStarsMethod
|
||||||
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
|
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
|
||||||
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
|
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
|
||||||
}`}>
|
}`}
|
||||||
<div className="w-7 h-7 flex items-center justify-center">
|
>
|
||||||
{getMethodIcon(method.id)}
|
<div className="flex h-7 w-7 items-center justify-center">{getMethodIcon(method.id)}</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-bold text-dark-100">{methodName}</h3>
|
<h3 className="text-lg font-bold text-dark-100">{methodName}</h3>
|
||||||
@@ -299,16 +359,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
key={opt.id}
|
key={opt.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedOption(opt.id)}
|
onClick={() => setSelectedOption(opt.id)}
|
||||||
className={`relative py-3 px-4 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`relative rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 ${
|
||||||
selectedOption === opt.id
|
selectedOption === opt.id
|
||||||
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40'
|
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40'
|
||||||
: 'bg-dark-800/70 text-dark-300 hover:bg-dark-700/70 border border-dark-700/50'
|
: 'border border-dark-700/50 bg-dark-800/70 text-dark-300 hover:bg-dark-700/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{opt.name}
|
{opt.name}
|
||||||
{selectedOption === opt.id && (
|
{selectedOption === opt.id && (
|
||||||
<span className="absolute top-1.5 right-1.5">
|
<span className="absolute right-1.5 top-1.5">
|
||||||
<span className="w-2 h-2 rounded-full bg-accent-500 block" />
|
<span className="block h-2 w-2 rounded-full bg-accent-500" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -321,11 +381,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
<div
|
||||||
|
className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
||||||
isInputFocused
|
isInputFocused
|
||||||
? 'ring-2 ring-accent-500/50 bg-dark-800'
|
? 'bg-dark-800 ring-2 ring-accent-500/50'
|
||||||
: 'bg-dark-800/70 border border-dark-700/50'
|
: 'border border-dark-700/50 bg-dark-800/70'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -335,9 +397,14 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
className="w-full h-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
className="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -349,16 +416,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isPending || !amount || parseFloat(amount) <= 0}
|
disabled={isPending || !amount || parseFloat(amount) <= 0}
|
||||||
className={`shrink-0 h-14 px-6 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden flex items-center justify-center gap-2 ${
|
className={`flex h-14 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 text-base font-bold transition-colors duration-200 ${
|
||||||
isPending || !amount || parseFloat(amount) <= 0
|
isPending || !amount || parseFloat(amount) <= 0
|
||||||
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
|
? 'cursor-not-allowed bg-dark-700 text-dark-500'
|
||||||
: isStarsMethod
|
: isStarsMethod
|
||||||
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
|
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
|
||||||
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
|
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SparklesIcon />
|
<SparklesIcon />
|
||||||
@@ -373,39 +440,54 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
{quickAmounts.length > 0 && (
|
{quickAmounts.length > 0 && (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{quickAmounts.map((a) => {
|
{quickAmounts.map((a) => {
|
||||||
const val = getQuickValue(a)
|
const val = getQuickValue(a);
|
||||||
const isSelected = amount === val
|
const isSelected = amount === val;
|
||||||
return (
|
return (
|
||||||
<BentoCard
|
<BentoCard
|
||||||
key={a}
|
key={a}
|
||||||
as="button"
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
onClick={() => {
|
||||||
|
setAmount(val);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}}
|
||||||
hover
|
hover
|
||||||
glow={isSelected}
|
glow={isSelected}
|
||||||
className={`flex flex-col items-center justify-center py-3 px-2 ${
|
className={`flex flex-col items-center justify-center px-2 py-3 ${
|
||||||
isSelected
|
isSelected ? 'border-accent-500/50 bg-accent-500/10' : ''
|
||||||
? 'border-accent-500/50 bg-accent-500/10'
|
|
||||||
: ''
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}>
|
<span
|
||||||
|
className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}
|
||||||
|
>
|
||||||
{formatAmount(a, 0)}
|
{formatAmount(a, 0)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs mt-0.5 ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}>
|
<span
|
||||||
|
className={`mt-0.5 text-xs ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}
|
||||||
|
>
|
||||||
{currencySymbol}
|
{currencySymbol}
|
||||||
</span>
|
</span>
|
||||||
</BentoCard>
|
</BentoCard>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-error-500/10 border border-error-500/20">
|
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
|
||||||
<svg className="w-5 h-5 text-error-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-5 w-5 shrink-0 text-error-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-error-400">{error}</span>
|
<span className="text-sm text-error-400">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,14 +495,19 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
{/* Payment link display - shown when URL is received */}
|
{/* Payment link display - shown when URL is received */}
|
||||||
{paymentUrl && (
|
{paymentUrl && (
|
||||||
<div className="space-y-3 p-4 rounded-2xl bg-success-500/10 border border-success-500/20">
|
<div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4">
|
||||||
<div className="flex items-center gap-2 text-success-400">
|
<div className="flex items-center gap-2 text-success-400">
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
<span className="font-semibold">{t('balance.paymentReady', 'Ссылка на оплату готова')}</span>
|
<span className="font-semibold">
|
||||||
|
{t('balance.paymentReady', 'Ссылка на оплату готова')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-dark-400">
|
<p className="text-sm text-dark-400">
|
||||||
{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')}
|
{t(
|
||||||
|
'balance.clickToOpenPayment',
|
||||||
|
'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
|
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
|
||||||
@@ -428,7 +515,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
href={paymentUrl}
|
href={paymentUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors"
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon />
|
<ExternalLinkIcon />
|
||||||
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
|
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
|
||||||
@@ -436,13 +523,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
{/* Copy and link display */}
|
{/* Copy and link display */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 min-w-0 px-3 py-2 rounded-lg bg-dark-800/70 border border-dark-700/50">
|
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
||||||
<p className="text-xs text-dark-500 truncate">{paymentUrl}</p>
|
<p className="truncate text-xs text-dark-500">{paymentUrl}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyUrl}
|
onClick={handleCopyUrl}
|
||||||
className={`shrink-0 p-2.5 rounded-lg transition-colors ${
|
className={`shrink-0 rounded-lg p-2.5 transition-colors ${
|
||||||
copied
|
copied
|
||||||
? 'bg-success-500/20 text-success-400'
|
? 'bg-success-500/20 text-success-400'
|
||||||
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||||
@@ -455,49 +542,48 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Render modal based on screen size - NO nested components!
|
// Render modal based on screen size - NO nested components!
|
||||||
const modalContent = isMobileScreen ? (
|
const modalContent = isMobileScreen ? (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
|
||||||
className="fixed inset-0 z-[9998] bg-black/70"
|
|
||||||
onClick={handleClose}
|
|
||||||
/>
|
|
||||||
{/* Bottom sheet */}
|
{/* Bottom sheet */}
|
||||||
<div
|
<div
|
||||||
data-modal-content
|
data-modal-content
|
||||||
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-3xl max-h-[90vh] flex flex-col overflow-hidden"
|
className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
|
||||||
style={{ paddingBottom: safeBottom ? `${safeBottom + 20}px` : 'max(20px, env(safe-area-inset-bottom))' }}
|
style={{
|
||||||
|
paddingBottom: safeBottom
|
||||||
|
? `${safeBottom + 20}px`
|
||||||
|
: 'max(20px, env(safe-area-inset-bottom))',
|
||||||
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Handle bar */}
|
{/* Handle bar */}
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
<div className="flex justify-center pb-1 pt-3">
|
||||||
<div className="w-10 h-1 rounded-full bg-dark-600" />
|
<div className="h-1 w-10 rounded-full bg-dark-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-2">
|
<div className="flex items-center justify-between px-5 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<WalletIcon />
|
<WalletIcon />
|
||||||
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
|
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400 transition-colors"
|
className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent mx-5" />
|
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-5 py-5 overflow-y-auto">
|
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
|
||||||
{contentJSX}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -507,35 +593,33 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-modal-content
|
data-modal-content
|
||||||
className="w-full max-w-md bg-dark-900 rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden"
|
className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-dark-800/80 to-dark-800/40 border-b border-dark-700/50">
|
<div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center text-accent-400">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
|
||||||
<WalletIcon />
|
<WalletIcon />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
|
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="p-2 -mr-1 rounded-xl hover:bg-dark-700 text-dark-400 transition-colors"
|
className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">{contentJSX}</div>
|
||||||
{contentJSX}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
return createPortal(modalContent, document.body)
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
return modalContent
|
return modalContent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,99 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { brandingApi } from '../../api/branding'
|
import { brandingApi } from '../../api/branding';
|
||||||
import { CheckIcon, CloseIcon } from './icons'
|
import { CheckIcon, CloseIcon } from './icons';
|
||||||
|
|
||||||
export function AnalyticsTab() {
|
export function AnalyticsTab() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Editing states
|
// Editing states
|
||||||
const [editingYandex, setEditingYandex] = useState(false)
|
const [editingYandex, setEditingYandex] = useState(false);
|
||||||
const [editingGoogleId, setEditingGoogleId] = useState(false)
|
const [editingGoogleId, setEditingGoogleId] = useState(false);
|
||||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false)
|
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false);
|
||||||
const [yandexValue, setYandexValue] = useState('')
|
const [yandexValue, setYandexValue] = useState('');
|
||||||
const [googleIdValue, setGoogleIdValue] = useState('')
|
const [googleIdValue, setGoogleIdValue] = useState('');
|
||||||
const [googleLabelValue, setGoogleLabelValue] = useState('')
|
const [googleLabelValue, setGoogleLabelValue] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Query
|
// Query
|
||||||
const { data: analytics } = useQuery({
|
const { data: analytics } = useQuery({
|
||||||
queryKey: ['analytics-counters'],
|
queryKey: ['analytics-counters'],
|
||||||
queryFn: brandingApi.getAnalyticsCounters,
|
queryFn: brandingApi.getAnalyticsCounters,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mutation
|
// Mutation
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: brandingApi.updateAnalyticsCounters,
|
mutationFn: brandingApi.updateAnalyticsCounters,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] })
|
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] });
|
||||||
setError(null)
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||||
setError(detail || t('common.error'))
|
setError(detail || t('common.error'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleSaveYandex = () => {
|
const handleSaveYandex = () => {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ yandex_metrika_id: yandexValue.trim() },
|
{ yandex_metrika_id: yandexValue.trim() },
|
||||||
{ onSuccess: () => setEditingYandex(false) },
|
{ onSuccess: () => setEditingYandex(false) },
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveGoogleId = () => {
|
const handleSaveGoogleId = () => {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ google_ads_id: googleIdValue.trim() },
|
{ google_ads_id: googleIdValue.trim() },
|
||||||
{ onSuccess: () => setEditingGoogleId(false) },
|
{ onSuccess: () => setEditingGoogleId(false) },
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveGoogleLabel = () => {
|
const handleSaveGoogleLabel = () => {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ google_ads_label: googleLabelValue.trim() },
|
{ google_ads_label: googleLabelValue.trim() },
|
||||||
{ onSuccess: () => setEditingGoogleLabel(false) },
|
{ onSuccess: () => setEditingGoogleLabel(false) },
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const yandexActive = Boolean(analytics?.yandex_metrika_id)
|
const yandexActive = Boolean(analytics?.yandex_metrika_id);
|
||||||
const googleActive = Boolean(analytics?.google_ads_id)
|
const googleActive = Boolean(analytics?.google_ads_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 rounded-2xl bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
|
<div className="rounded-2xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Yandex Metrika */}
|
{/* Yandex Metrika */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20">
|
||||||
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-dark-100">
|
<h3 className="text-lg font-semibold text-dark-100">
|
||||||
{t('admin.settings.yandexMetrika')}
|
{t('admin.settings.yandexMetrika')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
<span
|
||||||
yandexActive
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||||
? 'bg-success-500/15 text-success-400'
|
yandexActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||||
: 'bg-dark-700/50 text-dark-500'
|
}`}
|
||||||
}`}>
|
>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||||
|
/>
|
||||||
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
<p className="mb-5 ml-[52px] text-sm text-dark-400">
|
||||||
{t('admin.settings.yandexMetrikaDesc')}
|
{t('admin.settings.yandexMetrikaDesc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -106,73 +108,84 @@ export function AnalyticsTab() {
|
|||||||
value={yandexValue}
|
value={yandexValue}
|
||||||
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
|
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
|
||||||
placeholder={t('admin.settings.yandexIdPlaceholder')}
|
placeholder={t('admin.settings.yandexIdPlaceholder')}
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveYandex}
|
onClick={handleSaveYandex}
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingYandex(false); setError(null) }}
|
onClick={() => {
|
||||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
setEditingYandex(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-base ${analytics?.yandex_metrika_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
<span
|
||||||
|
className={`text-base ${analytics?.yandex_metrika_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||||
|
>
|
||||||
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
|
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setYandexValue(analytics?.yandex_metrika_id || '')
|
setYandexValue(analytics?.yandex_metrika_id || '');
|
||||||
setEditingYandex(true)
|
setEditingYandex(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-dark-500">
|
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
|
||||||
{t('admin.settings.yandexIdHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Google Ads */}
|
{/* Google Ads */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20 flex items-center justify-center flex-shrink-0">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20">
|
||||||
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/>
|
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-dark-100">
|
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.googleAds')}</h3>
|
||||||
{t('admin.settings.googleAds')}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
<span
|
||||||
googleActive
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||||
? 'bg-success-500/15 text-success-400'
|
googleActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||||
: 'bg-dark-700/50 text-dark-500'
|
}`}
|
||||||
}`}>
|
>
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
<span
|
||||||
|
className={`h-1.5 w-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||||
|
/>
|
||||||
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
<p className="mb-5 ml-[52px] text-sm text-dark-400">{t('admin.settings.googleAdsDesc')}</p>
|
||||||
{t('admin.settings.googleAdsDesc')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Conversion ID */}
|
{/* Conversion ID */}
|
||||||
@@ -187,45 +200,58 @@ export function AnalyticsTab() {
|
|||||||
value={googleIdValue}
|
value={googleIdValue}
|
||||||
onChange={(e) => setGoogleIdValue(e.target.value)}
|
onChange={(e) => setGoogleIdValue(e.target.value)}
|
||||||
placeholder={t('admin.settings.googleIdPlaceholder')}
|
placeholder={t('admin.settings.googleIdPlaceholder')}
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveGoogleId}
|
onClick={handleSaveGoogleId}
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingGoogleId(false); setError(null) }}
|
onClick={() => {
|
||||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
setEditingGoogleId(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-base ${analytics?.google_ads_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
<span
|
||||||
|
className={`text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||||
|
>
|
||||||
{analytics?.google_ads_id || t('admin.settings.notConfigured')}
|
{analytics?.google_ads_id || t('admin.settings.notConfigured')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGoogleIdValue(analytics?.google_ads_id || '')
|
setGoogleIdValue(analytics?.google_ads_id || '');
|
||||||
setEditingGoogleId(true)
|
setEditingGoogleId(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-dark-500">
|
<p className="text-xs text-dark-500">{t('admin.settings.googleIdHint')}</p>
|
||||||
{t('admin.settings.googleIdHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversion Label */}
|
{/* Conversion Label */}
|
||||||
@@ -240,55 +266,66 @@ export function AnalyticsTab() {
|
|||||||
value={googleLabelValue}
|
value={googleLabelValue}
|
||||||
onChange={(e) => setGoogleLabelValue(e.target.value)}
|
onChange={(e) => setGoogleLabelValue(e.target.value)}
|
||||||
placeholder={t('admin.settings.googleLabelPlaceholder')}
|
placeholder={t('admin.settings.googleLabelPlaceholder')}
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveGoogleLabel}
|
onClick={handleSaveGoogleLabel}
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingGoogleLabel(false); setError(null) }}
|
onClick={() => {
|
||||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
setEditingGoogleLabel(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`text-base ${analytics?.google_ads_label ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
<span
|
||||||
|
className={`text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||||
|
>
|
||||||
{analytics?.google_ads_label || t('admin.settings.notConfigured')}
|
{analytics?.google_ads_label || t('admin.settings.notConfigured')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGoogleLabelValue(analytics?.google_ads_label || '')
|
setGoogleLabelValue(analytics?.google_ads_label || '');
|
||||||
setEditingGoogleLabel(true)
|
setEditingGoogleLabel(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-dark-500">
|
<p className="text-xs text-dark-500">{t('admin.settings.googleLabelHint')}</p>
|
||||||
{t('admin.settings.googleLabelHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info block */}
|
{/* Info block */}
|
||||||
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
|
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-4">
|
||||||
<p className="text-sm text-dark-500 leading-relaxed">
|
<p className="text-sm leading-relaxed text-dark-500">{t('admin.settings.analyticsHint')}</p>
|
||||||
{t('admin.settings.analyticsHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +1,130 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { brandingApi, setCachedBranding } from '../../api/branding'
|
import { brandingApi, setCachedBranding } from '../../api/branding';
|
||||||
import { setCachedAnimationEnabled } from '../AnimatedBackground'
|
import { setCachedAnimationEnabled } from '../AnimatedBackground';
|
||||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'
|
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
|
||||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'
|
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
||||||
import { Toggle } from './Toggle'
|
import { Toggle } from './Toggle';
|
||||||
|
|
||||||
interface BrandingTabProps {
|
interface BrandingTabProps {
|
||||||
accentColor?: string
|
accentColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false);
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: branding } = useQuery({
|
const { data: branding } = useQuery({
|
||||||
queryKey: ['branding'],
|
queryKey: ['branding'],
|
||||||
queryFn: brandingApi.getBranding,
|
queryFn: brandingApi.getBranding,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: animationSettings } = useQuery({
|
const { data: animationSettings } = useQuery({
|
||||||
queryKey: ['animation-enabled'],
|
queryKey: ['animation-enabled'],
|
||||||
queryFn: brandingApi.getAnimationEnabled,
|
queryFn: brandingApi.getAnimationEnabled,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: fullscreenSettings } = useQuery({
|
const { data: fullscreenSettings } = useQuery({
|
||||||
queryKey: ['fullscreen-enabled'],
|
queryKey: ['fullscreen-enabled'],
|
||||||
queryFn: brandingApi.getFullscreenEnabled,
|
queryFn: brandingApi.getFullscreenEnabled,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: emailAuthSettings } = useQuery({
|
const { data: emailAuthSettings } = useQuery({
|
||||||
queryKey: ['email-auth-enabled'],
|
queryKey: ['email-auth-enabled'],
|
||||||
queryFn: brandingApi.getEmailAuthEnabled,
|
queryFn: brandingApi.getEmailAuthEnabled,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateBrandingMutation = useMutation({
|
const updateBrandingMutation = useMutation({
|
||||||
mutationFn: brandingApi.updateName,
|
mutationFn: brandingApi.updateName,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setCachedBranding(data)
|
setCachedBranding(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||||
setEditingName(false)
|
setEditingName(false);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const uploadLogoMutation = useMutation({
|
const uploadLogoMutation = useMutation({
|
||||||
mutationFn: brandingApi.uploadLogo,
|
mutationFn: brandingApi.uploadLogo,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setCachedBranding(data)
|
setCachedBranding(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const deleteLogoMutation = useMutation({
|
const deleteLogoMutation = useMutation({
|
||||||
mutationFn: brandingApi.deleteLogo,
|
mutationFn: brandingApi.deleteLogo,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setCachedBranding(data)
|
setCachedBranding(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateAnimationMutation = useMutation({
|
const updateAnimationMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setCachedAnimationEnabled(data.enabled)
|
setCachedAnimationEnabled(data.enabled);
|
||||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
|
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateFullscreenMutation = useMutation({
|
const updateFullscreenMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setCachedFullscreenEnabled(data.enabled)
|
setCachedFullscreenEnabled(data.enabled);
|
||||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
|
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateEmailAuthMutation = useMutation({
|
const updateEmailAuthMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
|
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] })
|
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
uploadLogoMutation.mutate(file)
|
uploadLogoMutation.mutate(file);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Logo & Name */}
|
{/* Logo & Name */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.logoAndName')}</h3>
|
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.settings.logoAndName')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-6">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className="w-20 h-20 rounded-2xl flex items-center justify-center text-3xl font-bold text-white overflow-hidden"
|
className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl text-3xl font-bold text-white"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`
|
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{branding?.has_custom_logo ? (
|
{branding?.has_custom_logo ? (
|
||||||
<img src={brandingApi.getLogoUrl(branding) ?? undefined} alt="Logo" className="w-full h-full object-cover" />
|
<img
|
||||||
|
src={brandingApi.getLogoUrl(branding) ?? undefined}
|
||||||
|
alt="Logo"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
branding?.logo_letter || 'V'
|
branding?.logo_letter || 'V'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="mt-3 flex gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -129,7 +135,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadLogoMutation.isPending}
|
disabled={uploadLogoMutation.isPending}
|
||||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-xl bg-dark-700 hover:bg-dark-600 text-dark-200 text-sm transition-colors disabled:opacity-50"
|
className="flex flex-1 items-center justify-center gap-1 rounded-xl bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<UploadIcon />
|
<UploadIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => deleteLogoMutation.mutate()}
|
onClick={() => deleteLogoMutation.mutate()}
|
||||||
disabled={deleteLogoMutation.isPending}
|
disabled={deleteLogoMutation.isPending}
|
||||||
className="px-3 py-2 rounded-xl bg-dark-700 hover:bg-error-500/20 text-dark-400 hover:text-error-400 transition-colors disabled:opacity-50"
|
className="rounded-xl bg-dark-700 px-3 py-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-sm font-medium text-dark-300 mb-2">{t('admin.settings.projectName')}</label>
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.settings.projectName')}
|
||||||
|
</label>
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500"
|
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateBrandingMutation.mutate(newName)}
|
onClick={() => updateBrandingMutation.mutate(newName)}
|
||||||
disabled={updateBrandingMutation.isPending}
|
disabled={updateBrandingMutation.isPending}
|
||||||
className="px-4 py-2 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
className="rounded-xl bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingName(false)}
|
onClick={() => setEditingName(false)}
|
||||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg text-dark-100">{branding?.name || t('admin.settings.notSpecified')}</span>
|
<span className="text-lg text-dark-100">
|
||||||
|
{branding?.name || t('admin.settings.notSpecified')}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNewName(branding?.name ?? '')
|
setNewName(branding?.name ?? '');
|
||||||
setEditingName(true)
|
setEditingName(true);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
>
|
>
|
||||||
<PencilIcon />
|
<PencilIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Animation & Fullscreen toggles */}
|
{/* Animation & Fullscreen toggles */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.interfaceOptions')}</h3>
|
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.settings.interfaceOptions')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-dark-100">{t('admin.settings.animatedBackground')}</span>
|
<span className="font-medium text-dark-100">
|
||||||
|
{t('admin.settings.animatedBackground')}
|
||||||
|
</span>
|
||||||
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
|
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
@@ -206,19 +220,23 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-dark-100">{t('admin.settings.autoFullscreen')}</span>
|
<span className="font-medium text-dark-100">
|
||||||
|
{t('admin.settings.autoFullscreen')}
|
||||||
|
</span>
|
||||||
<p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p>
|
<p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={fullscreenSettings?.enabled ?? false}
|
checked={fullscreenSettings?.enabled ?? false}
|
||||||
onChange={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
|
onChange={() =>
|
||||||
|
updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))
|
||||||
|
}
|
||||||
disabled={updateFullscreenMutation.isPending}
|
disabled={updateFullscreenMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
|
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
|
||||||
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
|
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
|
||||||
@@ -232,5 +250,5 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,48 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||||
import { StarIcon } from './icons'
|
import { StarIcon } from './icons';
|
||||||
import { SettingRow } from './SettingRow'
|
import { SettingRow } from './SettingRow';
|
||||||
|
|
||||||
interface FavoritesTabProps {
|
interface FavoritesTabProps {
|
||||||
settings: SettingDefinition[]
|
settings: SettingDefinition[];
|
||||||
isFavorite: (key: string) => boolean
|
isFavorite: (key: string) => boolean;
|
||||||
toggleFavorite: (key: string) => void
|
toggleFavorite: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) {
|
export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const updateSettingMutation = useMutation({
|
const updateSettingMutation = useMutation({
|
||||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
|
adminSettingsApi.updateSetting(key, value),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const resetSettingMutation = useMutation({
|
const resetSettingMutation = useMutation({
|
||||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (settings.length === 0) {
|
if (settings.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||||
<div className="flex justify-center mb-4 text-dark-500">
|
<div className="mb-4 flex justify-center text-dark-500">
|
||||||
<StarIcon filled={false} />
|
<StarIcon filled={false} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p>
|
<p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p>
|
||||||
<p className="text-dark-500 text-sm mt-1">{t('admin.settings.favoritesHint')}</p>
|
<p className="mt-1 text-sm text-dark-500">{t('admin.settings.favoritesHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
{settings.map((setting) => (
|
{settings.map((setting) => (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
key={setting.key}
|
key={setting.key}
|
||||||
@@ -55,5 +56,5 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { SettingDefinition } from '../../api/adminSettings'
|
import { SettingDefinition } from '../../api/adminSettings';
|
||||||
import { CheckIcon, CloseIcon, EditIcon } from './icons'
|
import { CheckIcon, CloseIcon, EditIcon } from './icons';
|
||||||
|
|
||||||
interface SettingInputProps {
|
interface SettingInputProps {
|
||||||
setting: SettingDefinition
|
setting: SettingDefinition;
|
||||||
onUpdate: (value: string) => void
|
onUpdate: (value: string) => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if value is likely JSON or multi-line
|
// Check if value is likely JSON or multi-line
|
||||||
function isLongValue(value: string | null | undefined): boolean {
|
function isLongValue(value: string | null | undefined): boolean {
|
||||||
if (!value) return false
|
if (!value) return false;
|
||||||
const str = String(value)
|
const str = String(value);
|
||||||
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{')
|
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if key suggests it's a list or JSON config
|
// Check if key suggests it's a list or JSON config
|
||||||
function isListOrJsonKey(key: string): boolean {
|
function isListOrJsonKey(key: string): boolean {
|
||||||
const lowerKey = key.toLowerCase()
|
const lowerKey = key.toLowerCase();
|
||||||
return (
|
return (
|
||||||
lowerKey.includes('_items') ||
|
lowerKey.includes('_items') ||
|
||||||
lowerKey.includes('_config') ||
|
lowerKey.includes('_config') ||
|
||||||
@@ -28,40 +28,40 @@ function isListOrJsonKey(key: string): boolean {
|
|||||||
lowerKey.includes('_periods') ||
|
lowerKey.includes('_periods') ||
|
||||||
lowerKey.includes('_discounts') ||
|
lowerKey.includes('_discounts') ||
|
||||||
lowerKey.includes('_packages')
|
lowerKey.includes('_packages')
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
|
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('');
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const currentValue = String(setting.current ?? '')
|
const currentValue = String(setting.current ?? '');
|
||||||
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key)
|
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current && isEditing) {
|
if (textareaRef.current && isEditing) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto';
|
||||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px'
|
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px';
|
||||||
}
|
}
|
||||||
}, [value, isEditing])
|
}, [value, isEditing]);
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
setValue(currentValue)
|
setValue(currentValue);
|
||||||
setIsEditing(true)
|
setIsEditing(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onUpdate(value)
|
onUpdate(value);
|
||||||
setIsEditing(false)
|
setIsEditing(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsEditing(false)
|
setIsEditing(false);
|
||||||
setValue('')
|
setValue('');
|
||||||
}
|
};
|
||||||
|
|
||||||
// Dropdown for choices
|
// Dropdown for choices
|
||||||
if (setting.choices && setting.choices.length > 0) {
|
if (setting.choices && setting.choices.length > 0) {
|
||||||
@@ -70,7 +70,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
|||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => onUpdate(e.target.value)}
|
onChange={(e) => onUpdate(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50 min-w-[140px] cursor-pointer"
|
className="min-w-[140px] cursor-pointer rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{setting.choices.map((choice, idx) => (
|
{setting.choices.map((choice, idx) => (
|
||||||
<option key={idx} value={String(choice.value)}>
|
<option key={idx} value={String(choice.value)}>
|
||||||
@@ -78,7 +78,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing mode - Textarea for long values
|
// Editing mode - Textarea for long values
|
||||||
@@ -90,26 +90,26 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') handleCancel()
|
if (e.key === 'Escape') handleCancel();
|
||||||
// Ctrl+Enter to save
|
// Ctrl+Enter to save
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave()
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Введите значение..."
|
placeholder="Введите значение..."
|
||||||
className="w-full bg-dark-700 border border-accent-500 rounded-xl px-4 py-3 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 font-mono resize-none min-h-[100px]"
|
className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
|
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="px-3 py-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors text-sm"
|
className="rounded-lg bg-dark-600 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:bg-dark-500"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="px-3 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors text-sm flex items-center gap-1.5"
|
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
Сохранить
|
Сохранить
|
||||||
@@ -117,7 +117,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing mode - Regular input
|
// Editing mode - Regular input
|
||||||
@@ -130,50 +130,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') handleSave()
|
if (e.key === 'Enter') handleSave();
|
||||||
if (e.key === 'Escape') handleCancel()
|
if (e.key === 'Escape') handleCancel();
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Введите значение..."
|
placeholder="Введите значение..."
|
||||||
className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 w-48 sm:w-56"
|
className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="p-2 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors"
|
className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
|
||||||
title="Сохранить (Enter)"
|
title="Сохранить (Enter)"
|
||||||
>
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="p-2 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors"
|
className="rounded-lg bg-dark-600 p-2 text-dark-300 transition-colors hover:bg-dark-500"
|
||||||
title="Отмена (Esc)"
|
title="Отмена (Esc)"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display mode - Long value preview
|
// Display mode - Long value preview
|
||||||
if (needsTextarea) {
|
if (needsTextarea) {
|
||||||
const displayValue = currentValue || '-'
|
const displayValue = currentValue || '-';
|
||||||
const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue
|
const previewValue =
|
||||||
|
displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-700 transition-colors disabled:opacity-50 text-left font-mono group"
|
className="group w-full rounded-xl border border-dark-600 bg-dark-700/50 px-4 py-3 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="break-all line-clamp-2 flex-1">{previewValue}</span>
|
<span className="line-clamp-2 flex-1 break-all">{previewValue}</span>
|
||||||
<span className="text-dark-500 group-hover:text-accent-400 transition-colors flex-shrink-0">
|
<span className="flex-shrink-0 text-dark-500 transition-colors group-hover:text-accent-400">
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display mode - Short value
|
// Display mode - Short value
|
||||||
@@ -181,12 +182,12 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
|||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-600 transition-colors disabled:opacity-50 min-w-[100px] text-left font-mono truncate max-w-[200px] flex items-center gap-2 group"
|
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span className="truncate flex-1">{currentValue || '-'}</span>
|
<span className="flex-1 truncate">{currentValue || '-'}</span>
|
||||||
<span className="text-dark-500 group-hover:text-accent-400 transition-colors opacity-0 group-hover:opacity-100">
|
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SettingDefinition } from '../../api/adminSettings'
|
import { SettingDefinition } from '../../api/adminSettings';
|
||||||
import { StarIcon, LockIcon, RefreshIcon } from './icons'
|
import { StarIcon, LockIcon, RefreshIcon } from './icons';
|
||||||
import { SettingInput } from './SettingInput'
|
import { SettingInput } from './SettingInput';
|
||||||
import { Toggle } from './Toggle'
|
import { Toggle } from './Toggle';
|
||||||
import { formatSettingKey, stripHtml } from './utils'
|
import { formatSettingKey, stripHtml } from './utils';
|
||||||
|
|
||||||
interface SettingRowProps {
|
interface SettingRowProps {
|
||||||
setting: SettingDefinition
|
setting: SettingDefinition;
|
||||||
isFavorite: boolean
|
isFavorite: boolean;
|
||||||
onToggleFavorite: () => void
|
onToggleFavorite: () => void;
|
||||||
onUpdate: (value: string) => void
|
onUpdate: (value: string) => void;
|
||||||
onReset: () => void
|
onReset: () => void;
|
||||||
isUpdating?: boolean
|
isUpdating?: boolean;
|
||||||
isResetting?: boolean
|
isResetting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingRow({
|
export function SettingRow({
|
||||||
@@ -22,18 +22,18 @@ export function SettingRow({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onReset,
|
onReset,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
isResetting
|
isResetting,
|
||||||
}: SettingRowProps) {
|
}: SettingRowProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const formattedKey = formatSettingKey(setting.name || setting.key)
|
const formattedKey = formatSettingKey(setting.name || setting.key);
|
||||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
|
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null
|
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
|
||||||
|
|
||||||
// Check if this is a long/complex value
|
// Check if this is a long/complex value
|
||||||
const isLongValue = (() => {
|
const isLongValue = (() => {
|
||||||
const val = String(setting.current ?? '')
|
const val = String(setting.current ?? '');
|
||||||
const key = setting.key.toLowerCase()
|
const key = setting.key.toLowerCase();
|
||||||
return (
|
return (
|
||||||
val.length > 50 ||
|
val.length > 50 ||
|
||||||
val.includes('\n') ||
|
val.includes('\n') ||
|
||||||
@@ -44,40 +44,40 @@ export function SettingRow({
|
|||||||
key.includes('_keywords') ||
|
key.includes('_keywords') ||
|
||||||
key.includes('_template') ||
|
key.includes('_template') ||
|
||||||
key.includes('_packages')
|
key.includes('_packages')
|
||||||
)
|
);
|
||||||
})()
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group p-4 sm:p-5 rounded-2xl bg-dark-800/40 border border-dark-700/40 hover:border-dark-600/60 hover:bg-dark-800/60 transition-all">
|
<div className="group rounded-2xl border border-dark-700/40 bg-dark-800/40 p-4 transition-all hover:border-dark-600/60 hover:bg-dark-800/60 sm:p-5">
|
||||||
{/* Header row - name, badges, favorite */}
|
{/* Header row - name, badges, favorite */}
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold text-dark-100 text-base">{displayName}</h3>
|
<h3 className="text-base font-semibold text-dark-100">{displayName}</h3>
|
||||||
{setting.has_override && (
|
{setting.has_override && (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400 font-medium">
|
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
|
||||||
{t('admin.settings.modified')}
|
{t('admin.settings.modified')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{setting.read_only && (
|
{setting.read_only && (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600/50 text-dark-400 font-medium flex items-center gap-1">
|
<span className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400">
|
||||||
<LockIcon />
|
<LockIcon />
|
||||||
{t('admin.settings.readOnly')}
|
{t('admin.settings.readOnly')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-dark-400 mt-1.5 leading-relaxed">{description}</p>
|
<p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Favorite button */}
|
{/* Favorite button */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleFavorite}
|
onClick={onToggleFavorite}
|
||||||
className={`p-2 rounded-xl transition-all flex-shrink-0 ${
|
className={`flex-shrink-0 rounded-xl p-2 transition-all ${
|
||||||
isFavorite
|
isFavorite
|
||||||
? 'text-warning-400 bg-warning-500/15 hover:bg-warning-500/25'
|
? 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
|
||||||
: 'text-dark-500 hover:text-warning-400 hover:bg-dark-700/50 opacity-0 group-hover:opacity-100'
|
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||||
>
|
>
|
||||||
@@ -87,17 +87,19 @@ export function SettingRow({
|
|||||||
|
|
||||||
{/* Setting key (muted) */}
|
{/* Setting key (muted) */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<code className="text-xs text-dark-500 font-mono bg-dark-900/50 px-2 py-1 rounded">
|
<code className="rounded bg-dark-900/50 px-2 py-1 font-mono text-xs text-dark-500">
|
||||||
{setting.key}
|
{setting.key}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Control section */}
|
{/* Control section */}
|
||||||
<div className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} pt-3 border-t border-dark-700/30`}>
|
<div
|
||||||
|
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
|
||||||
|
>
|
||||||
{setting.read_only ? (
|
{setting.read_only ? (
|
||||||
// Read-only display
|
// Read-only display
|
||||||
<div className="flex items-center gap-2 text-dark-300 bg-dark-700/30 rounded-lg px-4 py-2.5">
|
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
|
||||||
<span className="font-mono text-sm break-all">{String(setting.current ?? '-')}</span>
|
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : setting.type === 'bool' ? (
|
) : setting.type === 'bool' ? (
|
||||||
// Boolean toggle
|
// Boolean toggle
|
||||||
@@ -108,7 +110,11 @@ export function SettingRow({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={setting.current === true || setting.current === 'true'}
|
checked={setting.current === true || setting.current === 'true'}
|
||||||
onChange={() => onUpdate(setting.current === true || setting.current === 'true' ? 'false' : 'true')}
|
onChange={() =>
|
||||||
|
onUpdate(
|
||||||
|
setting.current === true || setting.current === 'true' ? 'false' : 'true',
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
/>
|
/>
|
||||||
{/* Reset button for boolean */}
|
{/* Reset button for boolean */}
|
||||||
@@ -116,7 +122,7 @@ export function SettingRow({
|
|||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
disabled={isResetting}
|
disabled={isResetting}
|
||||||
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50"
|
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||||
title={t('admin.settings.reset')}
|
title={t('admin.settings.reset')}
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
@@ -126,18 +132,16 @@ export function SettingRow({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Input field
|
// Input field
|
||||||
<div className={`${isLongValue ? 'w-full' : 'flex items-center gap-2 flex-1 justify-end'}`}>
|
<div
|
||||||
<SettingInput
|
className={`${isLongValue ? 'w-full' : 'flex flex-1 items-center justify-end gap-2'}`}
|
||||||
setting={setting}
|
>
|
||||||
onUpdate={onUpdate}
|
<SettingInput setting={setting} onUpdate={onUpdate} disabled={isUpdating} />
|
||||||
disabled={isUpdating}
|
|
||||||
/>
|
|
||||||
{/* Reset button for non-long values */}
|
{/* Reset button for non-long values */}
|
||||||
{!isLongValue && setting.has_override && (
|
{!isLongValue && setting.has_override && (
|
||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
disabled={isResetting}
|
disabled={isResetting}
|
||||||
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
|
className="flex-shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||||
title={t('admin.settings.reset')}
|
title={t('admin.settings.reset')}
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
@@ -153,7 +157,7 @@ export function SettingRow({
|
|||||||
<button
|
<button
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
disabled={isResetting}
|
disabled={isResetting}
|
||||||
className="px-3 py-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 text-sm flex items-center gap-1.5"
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||||
title={t('admin.settings.reset')}
|
title={t('admin.settings.reset')}
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
@@ -162,5 +166,5 @@ export function SettingRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SearchIcon, CloseIcon } from './icons'
|
import { SearchIcon, CloseIcon } from './icons';
|
||||||
|
|
||||||
interface SettingsSearchProps {
|
interface SettingsSearchProps {
|
||||||
searchQuery: string
|
searchQuery: string;
|
||||||
setSearchQuery: (query: string) => void
|
setSearchQuery: (query: string) => void;
|
||||||
resultsCount?: number
|
resultsCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
|
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('admin.settings.searchPlaceholder')}
|
placeholder={t('admin.settings.searchPlaceholder')}
|
||||||
className="w-48 lg:w-64 pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<SettingsSearchProps, 'resultsCount'>) {
|
export function SettingsSearchMobile({
|
||||||
const { t } = useTranslation()
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-3 sm:hidden">
|
<div className="relative mt-3 sm:hidden">
|
||||||
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder={t('admin.settings.searchPlaceholder')}
|
placeholder={t('admin.settings.searchPlaceholder')}
|
||||||
className="w-full pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) {
|
export function SettingsSearchResults({
|
||||||
if (!searchQuery.trim()) return null
|
searchQuery,
|
||||||
|
resultsCount,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
resultsCount: number;
|
||||||
|
}) {
|
||||||
|
if (!searchQuery.trim()) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||||
<span className="text-dark-400">
|
<span className="text-dark-400">
|
||||||
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
|
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
|
||||||
</span>
|
</span>
|
||||||
{resultsCount > 0 && (
|
{resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
|
||||||
<span className="text-dark-500">по запросу «{searchQuery}»</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'
|
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
||||||
|
|
||||||
interface SettingsSidebarProps {
|
interface SettingsSidebarProps {
|
||||||
activeSection: string
|
activeSection: string;
|
||||||
setActiveSection: (section: string) => void
|
setActiveSection: (section: string) => void;
|
||||||
mobileMenuOpen: boolean
|
mobileMenuOpen: boolean;
|
||||||
setMobileMenuOpen: (open: boolean) => void
|
setMobileMenuOpen: (open: boolean) => void;
|
||||||
favoritesCount: number
|
favoritesCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSidebar({
|
export function SettingsSidebar({
|
||||||
@@ -15,27 +15,27 @@ export function SettingsSidebar({
|
|||||||
setActiveSection,
|
setActiveSection,
|
||||||
mobileMenuOpen,
|
mobileMenuOpen,
|
||||||
setMobileMenuOpen,
|
setMobileMenuOpen,
|
||||||
favoritesCount
|
favoritesCount,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`
|
<aside
|
||||||
fixed lg:sticky lg:top-0 inset-y-0 left-0 z-50
|
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
|
||||||
w-64 h-screen bg-dark-900 border-r border-dark-700/50 flex-shrink-0
|
>
|
||||||
transform transition-transform duration-200 ease-in-out
|
|
||||||
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
|
||||||
`}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-dark-700/50">
|
<div className="border-b border-dark-700/50 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link to="/admin" className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors">
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className="ml-auto p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
|
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -43,39 +43,39 @@ export function SettingsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
<nav className="p-2 space-y-1 overflow-y-auto max-h-[calc(100vh-80px)]">
|
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
|
||||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||||
<div key={section.id}>
|
<div key={section.id}>
|
||||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const isActive = activeSection === item.id
|
const isActive = activeSection === item.id;
|
||||||
const hasIcon = item.iconType === 'star'
|
const hasIcon = item.iconType === 'star';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection(item.id)
|
setActiveSection(item.id);
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent-500/10 text-accent-400'
|
? 'bg-accent-500/10 text-accent-400'
|
||||||
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800/50'
|
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
||||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
{item.id === 'favorites' && favoritesCount > 0 && (
|
||||||
<span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400">
|
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||||
{favoritesCount}
|
{favoritesCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||||
import { ChevronDownIcon } from './icons'
|
import { ChevronDownIcon } from './icons';
|
||||||
import { SettingRow } from './SettingRow'
|
import { SettingRow } from './SettingRow';
|
||||||
|
|
||||||
interface CategoryGroup {
|
interface CategoryGroup {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
settings: SettingDefinition[]
|
settings: SettingDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsTabProps {
|
interface SettingsTabProps {
|
||||||
categories: CategoryGroup[]
|
categories: CategoryGroup[];
|
||||||
searchQuery: string
|
searchQuery: string;
|
||||||
filteredSettings: SettingDefinition[]
|
filteredSettings: SettingDefinition[];
|
||||||
isFavorite: (key: string) => boolean
|
isFavorite: (key: string) => boolean;
|
||||||
toggleFavorite: (key: string) => void
|
toggleFavorite: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsTab({
|
export function SettingsTab({
|
||||||
@@ -24,49 +24,50 @@ export function SettingsTab({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
filteredSettings,
|
filteredSettings,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
toggleFavorite
|
toggleFavorite,
|
||||||
}: SettingsTabProps) {
|
}: SettingsTabProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggleSection = (key: string) => {
|
const toggleSection = (key: string) => {
|
||||||
setExpandedSections(prev => {
|
setExpandedSections((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(key)) {
|
if (next.has(key)) {
|
||||||
next.delete(key)
|
next.delete(key);
|
||||||
} else {
|
} else {
|
||||||
next.add(key)
|
next.add(key);
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateSettingMutation = useMutation({
|
const updateSettingMutation = useMutation({
|
||||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
|
adminSettingsApi.updateSetting(key, value),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const resetSettingMutation = useMutation({
|
const resetSettingMutation = useMutation({
|
||||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// If searching, show flat list
|
// If searching, show flat list
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredSettings.length === 0 ? (
|
{filteredSettings.length === 0 ? (
|
||||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
{filteredSettings.map((setting) => (
|
{filteredSettings.map((setting) => (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
key={setting.key}
|
key={setting.key}
|
||||||
@@ -82,46 +83,50 @@ export function SettingsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show accordion for subcategories
|
// Show accordion for subcategories
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{categories.map((cat) => {
|
{categories.map((cat) => {
|
||||||
const isExpanded = expandedSections.has(cat.key)
|
const isExpanded = expandedSections.has(cat.key);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cat.key}
|
key={cat.key}
|
||||||
className="rounded-2xl bg-dark-800/30 border border-dark-700/30 overflow-hidden"
|
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
|
||||||
>
|
>
|
||||||
{/* Accordion header */}
|
{/* Accordion header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(cat.key)}
|
onClick={() => toggleSection(cat.key)}
|
||||||
className="w-full flex items-center justify-between p-4 hover:bg-dark-800/50 transition-colors"
|
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium text-dark-100">{cat.label}</span>
|
<span className="font-medium text-dark-100">{cat.label}</span>
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-700 text-dark-400">
|
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
|
||||||
{cat.settings.length}
|
{cat.settings.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`transition-transform duration-200 text-dark-400 ${isExpanded ? 'rotate-180' : ''}`}>
|
<div
|
||||||
|
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
>
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Accordion content */}
|
{/* Accordion content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="p-4 pt-0 border-t border-dark-700/30">
|
<div className="border-t border-dark-700/30 p-4 pt-0">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
|
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
|
||||||
{cat.settings.map((setting) => (
|
{cat.settings.map((setting) => (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
key={setting.key}
|
key={setting.key}
|
||||||
setting={setting}
|
setting={setting}
|
||||||
isFavorite={isFavorite(setting.key)}
|
isFavorite={isFavorite(setting.key)}
|
||||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||||
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
onUpdate={(value) =>
|
||||||
|
updateSettingMutation.mutate({ key: setting.key, value })
|
||||||
|
}
|
||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||||
isUpdating={updateSettingMutation.isPending}
|
isUpdating={updateSettingMutation.isPending}
|
||||||
isResetting={resetSettingMutation.isPending}
|
isResetting={resetSettingMutation.isPending}
|
||||||
@@ -131,14 +136,14 @@ export function SettingsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{categories.length === 0 && (
|
{categories.length === 0 && (
|
||||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,107 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { themeColorsApi } from '../../api/themeColors'
|
import { themeColorsApi } from '../../api/themeColors';
|
||||||
import { DEFAULT_THEME_COLORS } from '../../types/theme'
|
import { DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||||
import { ColorPicker } from '../ColorPicker'
|
import { ColorPicker } from '../ColorPicker';
|
||||||
import { applyThemeColors } from '../../hooks/useThemeColors'
|
import { applyThemeColors } from '../../hooks/useThemeColors';
|
||||||
import { updateEnabledThemesCache } from '../../hooks/useTheme'
|
import { updateEnabledThemesCache } from '../../hooks/useTheme';
|
||||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons'
|
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
|
||||||
import { Toggle } from './Toggle'
|
import { Toggle } from './Toggle';
|
||||||
import { THEME_PRESETS } from './constants'
|
import { THEME_PRESETS } from './constants';
|
||||||
|
|
||||||
export function ThemeTab() {
|
export function ThemeTab() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']))
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']));
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
const toggleSection = (section: string) => {
|
||||||
setExpandedSections(prev => {
|
setExpandedSections((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(section)) {
|
if (next.has(section)) {
|
||||||
next.delete(section)
|
next.delete(section);
|
||||||
} else {
|
} else {
|
||||||
next.add(section)
|
next.add(section);
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: themeColors } = useQuery({
|
const { data: themeColors } = useQuery({
|
||||||
queryKey: ['theme-colors'],
|
queryKey: ['theme-colors'],
|
||||||
queryFn: themeColorsApi.getColors,
|
queryFn: themeColorsApi.getColors,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: enabledThemes } = useQuery({
|
const { data: enabledThemes } = useQuery({
|
||||||
queryKey: ['enabled-themes'],
|
queryKey: ['enabled-themes'],
|
||||||
queryFn: themeColorsApi.getEnabledThemes,
|
queryFn: themeColorsApi.getEnabledThemes,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateColorsMutation = useMutation({
|
const updateColorsMutation = useMutation({
|
||||||
mutationFn: themeColorsApi.updateColors,
|
mutationFn: themeColorsApi.updateColors,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
applyThemeColors(data)
|
applyThemeColors(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const resetColorsMutation = useMutation({
|
const resetColorsMutation = useMutation({
|
||||||
mutationFn: themeColorsApi.resetColors,
|
mutationFn: themeColorsApi.resetColors,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
applyThemeColors(data)
|
applyThemeColors(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateEnabledThemesMutation = useMutation({
|
const updateEnabledThemesMutation = useMutation({
|
||||||
mutationFn: themeColorsApi.updateEnabledThemes,
|
mutationFn: themeColorsApi.updateEnabledThemes,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
updateEnabledThemesCache(data)
|
updateEnabledThemesCache(data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] })
|
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Theme toggles */}
|
{/* Theme toggles */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.availableThemes')}</h3>
|
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.settings.availableThemes')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<MoonIcon />
|
<MoonIcon />
|
||||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.darkTheme')}</span>
|
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||||
|
{t('admin.settings.darkTheme')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={enabledThemes?.dark ?? true}
|
checked={enabledThemes?.dark ?? true}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return
|
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return;
|
||||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) })
|
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) });
|
||||||
}}
|
}}
|
||||||
disabled={updateEnabledThemesMutation.isPending}
|
disabled={updateEnabledThemesMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<SunIcon />
|
<SunIcon />
|
||||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.lightTheme')}</span>
|
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||||
|
{t('admin.settings.lightTheme')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={enabledThemes?.light ?? true}
|
checked={enabledThemes?.light ?? true}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return
|
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return;
|
||||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) })
|
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) });
|
||||||
}}
|
}}
|
||||||
disabled={updateEnabledThemesMutation.isPending}
|
disabled={updateEnabledThemesMutation.isPending}
|
||||||
/>
|
/>
|
||||||
@@ -104,30 +110,34 @@ export function ThemeTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Presets */}
|
{/* Quick Presets */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection('presets')}
|
onClick={() => toggleSection('presets')}
|
||||||
className="w-full flex items-center justify-between"
|
className="flex w-full items-center justify-between"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.settings.quickPresets')}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.quickPresets')}</h3>
|
|
||||||
<div className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}>
|
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expandedSections.has('presets') && (
|
{expandedSections.has('presets') && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{THEME_PRESETS.map((preset) => (
|
{THEME_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
onClick={() => updateColorsMutation.mutate(preset.colors)}
|
onClick={() => updateColorsMutation.mutate(preset.colors)}
|
||||||
disabled={updateColorsMutation.isPending}
|
disabled={updateColorsMutation.isPending}
|
||||||
className="p-3 rounded-xl border border-dark-600 hover:border-dark-500 transition-all hover:scale-[1.02]"
|
className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
|
||||||
style={{ backgroundColor: preset.colors.darkBackground }}
|
style={{ backgroundColor: preset.colors.darkBackground }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-4 h-4 rounded-full ring-2 ring-white/20"
|
className="h-4 w-4 rounded-full ring-2 ring-white/20"
|
||||||
style={{ backgroundColor: preset.colors.accent }}
|
style={{ backgroundColor: preset.colors.accent }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-medium" style={{ color: preset.colors.darkText }}>
|
<span className="text-xs font-medium" style={{ color: preset.colors.darkText }}>
|
||||||
@@ -135,9 +145,18 @@ export function ThemeTab() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.success }} />
|
<div
|
||||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.warning }} />
|
className="h-3 w-3 rounded"
|
||||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.error }} />
|
style={{ backgroundColor: preset.colors.success }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{ backgroundColor: preset.colors.warning }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{ backgroundColor: preset.colors.error }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -146,13 +165,17 @@ export function ThemeTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Colors */}
|
{/* Custom Colors */}
|
||||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection('colors')}
|
onClick={() => toggleSection('colors')}
|
||||||
className="w-full flex items-center justify-between"
|
className="flex w-full items-center justify-between"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.settings.customColors')}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.customColors')}</h3>
|
|
||||||
<div className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}>
|
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -161,7 +184,9 @@ export function ThemeTab() {
|
|||||||
<div className="mt-4 space-y-6">
|
<div className="mt-4 space-y-6">
|
||||||
{/* Accent */}
|
{/* Accent */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.accentColor')}</h4>
|
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.settings.accentColor')}
|
||||||
|
</h4>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
label={t('theme.accent')}
|
label={t('theme.accent')}
|
||||||
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
||||||
@@ -172,10 +197,10 @@ export function ThemeTab() {
|
|||||||
|
|
||||||
{/* Dark theme */}
|
{/* Dark theme */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||||
<MoonIcon /> {t('admin.settings.darkTheme')}
|
<MoonIcon /> {t('admin.settings.darkTheme')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
label={t('admin.settings.colors.background')}
|
label={t('admin.settings.colors.background')}
|
||||||
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
||||||
@@ -205,10 +230,10 @@ export function ThemeTab() {
|
|||||||
|
|
||||||
{/* Light theme */}
|
{/* Light theme */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||||
<SunIcon /> {t('admin.settings.lightTheme')}
|
<SunIcon /> {t('admin.settings.lightTheme')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
label={t('admin.settings.colors.background')}
|
label={t('admin.settings.colors.background')}
|
||||||
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
||||||
@@ -238,8 +263,10 @@ export function ThemeTab() {
|
|||||||
|
|
||||||
{/* Status colors */}
|
{/* Status colors */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.statusColors')}</h4>
|
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
{t('admin.settings.statusColors')}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
label={t('admin.settings.colors.success')}
|
label={t('admin.settings.colors.success')}
|
||||||
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
||||||
@@ -265,7 +292,7 @@ export function ThemeTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => resetColorsMutation.mutate()}
|
onClick={() => resetColorsMutation.mutate()}
|
||||||
disabled={resetColorsMutation.isPending}
|
disabled={resetColorsMutation.isPending}
|
||||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors disabled:opacity-50"
|
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('admin.settings.resetAllColors')}
|
{t('admin.settings.resetAllColors')}
|
||||||
</button>
|
</button>
|
||||||
@@ -273,5 +300,5 @@ export function ThemeTab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
interface ToggleProps {
|
interface ToggleProps {
|
||||||
checked: boolean
|
checked: boolean;
|
||||||
onChange: () => void
|
onChange: () => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||||
@@ -9,13 +9,15 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={onChange}
|
onClick={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||||
checked ? 'bg-accent-500' : 'bg-dark-600'
|
checked ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
>
|
>
|
||||||
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200 ${
|
<div
|
||||||
|
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
|
||||||
checked ? 'translate-x-6' : 'translate-x-0'
|
checked ? 'translate-x-6' : 'translate-x-0'
|
||||||
}`} />
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme'
|
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||||
|
|
||||||
// Menu item types
|
// Menu item types
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
id: string
|
id: string;
|
||||||
iconType?: 'star' | null
|
iconType?: 'star' | null;
|
||||||
categories?: string[]
|
categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuSection {
|
export interface MenuSection {
|
||||||
id: string
|
id: string;
|
||||||
items: MenuItem[]
|
items: MenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar menu configuration
|
// Sidebar menu configuration
|
||||||
@@ -21,36 +21,215 @@ export const MENU_SECTIONS: MenuSection[] = [
|
|||||||
{ id: 'branding', iconType: null },
|
{ id: 'branding', iconType: null },
|
||||||
{ id: 'theme', iconType: null },
|
{ id: 'theme', iconType: null },
|
||||||
{ id: 'analytics', iconType: null },
|
{ id: 'analytics', iconType: null },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'payments', iconType: null, categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'] },
|
{
|
||||||
{ id: 'subscriptions', iconType: null, categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'] },
|
id: 'payments',
|
||||||
{ id: 'interface', iconType: null, categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'] },
|
iconType: null,
|
||||||
{ id: 'notifications', iconType: null, categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'] },
|
categories: [
|
||||||
|
'PAYMENT',
|
||||||
|
'PAYMENT_VERIFICATION',
|
||||||
|
'YOOKASSA',
|
||||||
|
'CRYPTOBOT',
|
||||||
|
'HELEKET',
|
||||||
|
'PLATEGA',
|
||||||
|
'TRIBUTE',
|
||||||
|
'MULENPAY',
|
||||||
|
'PAL24',
|
||||||
|
'WATA',
|
||||||
|
'TELEGRAM',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subscriptions',
|
||||||
|
iconType: null,
|
||||||
|
categories: [
|
||||||
|
'SUBSCRIPTIONS_CORE',
|
||||||
|
'SIMPLE_SUBSCRIPTION',
|
||||||
|
'PERIODS',
|
||||||
|
'SUBSCRIPTION_PRICES',
|
||||||
|
'TRAFFIC',
|
||||||
|
'TRAFFIC_PACKAGES',
|
||||||
|
'TRIAL',
|
||||||
|
'AUTOPAY',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interface',
|
||||||
|
iconType: null,
|
||||||
|
categories: [
|
||||||
|
'INTERFACE',
|
||||||
|
'INTERFACE_BRANDING',
|
||||||
|
'INTERFACE_SUBSCRIPTION',
|
||||||
|
'CONNECT_BUTTON',
|
||||||
|
'MINIAPP',
|
||||||
|
'HAPP',
|
||||||
|
'SKIP',
|
||||||
|
'ADDITIONAL',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
iconType: null,
|
||||||
|
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
||||||
|
},
|
||||||
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
||||||
{ id: 'system', iconType: null, categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'] },
|
{
|
||||||
{ id: 'users', iconType: null, categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'] },
|
id: 'system',
|
||||||
]
|
iconType: null,
|
||||||
}
|
categories: [
|
||||||
]
|
'CORE',
|
||||||
|
'REMNAWAVE',
|
||||||
|
'SERVER_STATUS',
|
||||||
|
'MONITORING',
|
||||||
|
'MAINTENANCE',
|
||||||
|
'BACKUP',
|
||||||
|
'VERSION',
|
||||||
|
'WEB_API',
|
||||||
|
'WEBHOOK',
|
||||||
|
'LOG',
|
||||||
|
'DEBUG',
|
||||||
|
'EXTERNAL_ADMIN',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
iconType: null,
|
||||||
|
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Theme preset type
|
// Theme preset type
|
||||||
export interface ThemePreset {
|
export interface ThemePreset {
|
||||||
id: string
|
id: string;
|
||||||
colors: ThemeColors
|
colors: ThemeColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme presets
|
// Theme presets
|
||||||
export const THEME_PRESETS: ThemePreset[] = [
|
export const THEME_PRESETS: ThemePreset[] = [
|
||||||
{ id: 'standard', colors: DEFAULT_THEME_COLORS },
|
{ id: 'standard', colors: DEFAULT_THEME_COLORS },
|
||||||
{ id: 'ocean', colors: { accent: '#0ea5e9', darkBackground: '#0c1222', darkSurface: '#1e293b', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', lightBackground: '#e0f2fe', lightSurface: '#f0f9ff', lightText: '#0c4a6e', lightTextSecondary: '#0369a1', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
{
|
||||||
{ id: 'forest', colors: { accent: '#22c55e', darkBackground: '#0a1a0f', darkSurface: '#14532d', darkText: '#f0fdf4', darkTextSecondary: '#86efac', lightBackground: '#dcfce7', lightSurface: '#f0fdf4', lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
id: 'ocean',
|
||||||
{ id: 'sunset', colors: { accent: '#f97316', darkBackground: '#1c1009', darkSurface: '#2d1a0e', darkText: '#fff7ed', darkTextSecondary: '#fdba74', lightBackground: '#ffedd5', lightSurface: '#fff7ed', lightText: '#7c2d12', lightTextSecondary: '#c2410c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
colors: {
|
||||||
{ id: 'violet', colors: { accent: '#a855f7', darkBackground: '#0f0a1a', darkSurface: '#1e1b2e', darkText: '#faf5ff', darkTextSecondary: '#c4b5fd', lightBackground: '#f3e8ff', lightSurface: '#faf5ff', lightText: '#581c87', lightTextSecondary: '#7e22ce', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
accent: '#0ea5e9',
|
||||||
{ id: 'rose', colors: { accent: '#f43f5e', darkBackground: '#1a0a10', darkSurface: '#2d1520', darkText: '#fff1f2', darkTextSecondary: '#fda4af', lightBackground: '#ffe4e6', lightSurface: '#fff1f2', lightText: '#881337', lightTextSecondary: '#be123c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
darkBackground: '#0c1222',
|
||||||
{ id: 'midnight', colors: { accent: '#6366f1', darkBackground: '#030712', darkSurface: '#111827', darkText: '#f9fafb', darkTextSecondary: '#9ca3af', lightBackground: '#e5e7eb', lightSurface: '#f3f4f6', lightText: '#111827', lightTextSecondary: '#4b5563', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
darkSurface: '#1e293b',
|
||||||
{ id: 'turquoise', colors: { accent: '#14b8a6', darkBackground: '#0a1614', darkSurface: '#134e4a', darkText: '#f0fdfa', darkTextSecondary: '#5eead4', lightBackground: '#ccfbf1', lightSurface: '#f0fdfa', lightText: '#134e4a', lightTextSecondary: '#0f766e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
darkText: '#f1f5f9',
|
||||||
]
|
darkTextSecondary: '#94a3b8',
|
||||||
|
lightBackground: '#e0f2fe',
|
||||||
|
lightSurface: '#f0f9ff',
|
||||||
|
lightText: '#0c4a6e',
|
||||||
|
lightTextSecondary: '#0369a1',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forest',
|
||||||
|
colors: {
|
||||||
|
accent: '#22c55e',
|
||||||
|
darkBackground: '#0a1a0f',
|
||||||
|
darkSurface: '#14532d',
|
||||||
|
darkText: '#f0fdf4',
|
||||||
|
darkTextSecondary: '#86efac',
|
||||||
|
lightBackground: '#dcfce7',
|
||||||
|
lightSurface: '#f0fdf4',
|
||||||
|
lightText: '#14532d',
|
||||||
|
lightTextSecondary: '#166534',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sunset',
|
||||||
|
colors: {
|
||||||
|
accent: '#f97316',
|
||||||
|
darkBackground: '#1c1009',
|
||||||
|
darkSurface: '#2d1a0e',
|
||||||
|
darkText: '#fff7ed',
|
||||||
|
darkTextSecondary: '#fdba74',
|
||||||
|
lightBackground: '#ffedd5',
|
||||||
|
lightSurface: '#fff7ed',
|
||||||
|
lightText: '#7c2d12',
|
||||||
|
lightTextSecondary: '#c2410c',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'violet',
|
||||||
|
colors: {
|
||||||
|
accent: '#a855f7',
|
||||||
|
darkBackground: '#0f0a1a',
|
||||||
|
darkSurface: '#1e1b2e',
|
||||||
|
darkText: '#faf5ff',
|
||||||
|
darkTextSecondary: '#c4b5fd',
|
||||||
|
lightBackground: '#f3e8ff',
|
||||||
|
lightSurface: '#faf5ff',
|
||||||
|
lightText: '#581c87',
|
||||||
|
lightTextSecondary: '#7e22ce',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rose',
|
||||||
|
colors: {
|
||||||
|
accent: '#f43f5e',
|
||||||
|
darkBackground: '#1a0a10',
|
||||||
|
darkSurface: '#2d1520',
|
||||||
|
darkText: '#fff1f2',
|
||||||
|
darkTextSecondary: '#fda4af',
|
||||||
|
lightBackground: '#ffe4e6',
|
||||||
|
lightSurface: '#fff1f2',
|
||||||
|
lightText: '#881337',
|
||||||
|
lightTextSecondary: '#be123c',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'midnight',
|
||||||
|
colors: {
|
||||||
|
accent: '#6366f1',
|
||||||
|
darkBackground: '#030712',
|
||||||
|
darkSurface: '#111827',
|
||||||
|
darkText: '#f9fafb',
|
||||||
|
darkTextSecondary: '#9ca3af',
|
||||||
|
lightBackground: '#e5e7eb',
|
||||||
|
lightSurface: '#f3f4f6',
|
||||||
|
lightText: '#111827',
|
||||||
|
lightTextSecondary: '#4b5563',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'turquoise',
|
||||||
|
colors: {
|
||||||
|
accent: '#14b8a6',
|
||||||
|
darkBackground: '#0a1614',
|
||||||
|
darkSurface: '#134e4a',
|
||||||
|
darkText: '#f0fdfa',
|
||||||
|
darkTextSecondary: '#5eead4',
|
||||||
|
lightBackground: '#ccfbf1',
|
||||||
|
lightSurface: '#f0fdfa',
|
||||||
|
lightText: '#134e4a',
|
||||||
|
lightTextSecondary: '#0f766e',
|
||||||
|
success: '#22c55e',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,91 +1,141 @@
|
|||||||
// Admin Settings Icons
|
// Admin Settings Icons
|
||||||
|
|
||||||
export const BackIcon = () => (
|
export const BackIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const SearchIcon = () => (
|
export const SearchIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const StarIcon = ({ filled }: { filled?: boolean }) => (
|
export const StarIcon = ({ filled }: { filled?: boolean }) => (
|
||||||
<svg className="w-5 h-5" fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
className="h-5 w-5"
|
||||||
|
fill={filled ? 'currentColor' : 'none'}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const ChevronDownIcon = () => (
|
export const ChevronDownIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const UploadIcon = () => (
|
export const UploadIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const TrashIcon = () => (
|
export const TrashIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const PencilIcon = () => (
|
export const PencilIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const RefreshIcon = () => (
|
export const RefreshIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const LockIcon = () => (
|
export const LockIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const CheckIcon = () => (
|
export const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const CloseIcon = () => (
|
export const CloseIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const SunIcon = () => (
|
export const SunIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const MoonIcon = () => (
|
export const MoonIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const MenuIcon = () => (
|
export const MenuIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export const EditIcon = () => (
|
export const EditIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
// Components
|
// Components
|
||||||
export * from './icons'
|
export * from './icons';
|
||||||
export * from './Toggle'
|
export * from './Toggle';
|
||||||
export * from './SettingInput'
|
export * from './SettingInput';
|
||||||
export * from './SettingRow'
|
export * from './SettingRow';
|
||||||
export * from './AnalyticsTab'
|
export * from './AnalyticsTab';
|
||||||
export * from './BrandingTab'
|
export * from './BrandingTab';
|
||||||
export * from './ThemeTab'
|
export * from './ThemeTab';
|
||||||
export * from './FavoritesTab'
|
export * from './FavoritesTab';
|
||||||
export * from './SettingsTab'
|
export * from './SettingsTab';
|
||||||
export * from './SettingsSidebar'
|
export * from './SettingsSidebar';
|
||||||
export * from './SettingsSearch'
|
export * from './SettingsSearch';
|
||||||
|
|
||||||
// Constants and utils
|
// Constants and utils
|
||||||
export * from './constants'
|
export * from './constants';
|
||||||
export * from './utils'
|
export * from './utils';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Format setting key from Snake_Case / CamelCase to readable text
|
// Format setting key from Snake_Case / CamelCase to readable text
|
||||||
export function formatSettingKey(name: string): string {
|
export function formatSettingKey(name: string): string {
|
||||||
if (!name) return ''
|
if (!name) return '';
|
||||||
|
|
||||||
return name
|
return (
|
||||||
|
name
|
||||||
// CamelCase -> spaces
|
// CamelCase -> spaces
|
||||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||||
// snake_case -> spaces
|
// snake_case -> spaces
|
||||||
@@ -11,12 +12,13 @@ export function formatSettingKey(name: string): string {
|
|||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
// Capitalize first letter
|
// Capitalize first letter
|
||||||
.replace(/^./, c => c.toUpperCase())
|
.replace(/^./, (c) => c.toUpperCase())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip HTML tags and template descriptions from setting descriptions
|
// Strip HTML tags and template descriptions from setting descriptions
|
||||||
export function stripHtml(html: string): string {
|
export function stripHtml(html: string): string {
|
||||||
if (!html) return ''
|
if (!html) return '';
|
||||||
const cleaned = html
|
const cleaned = html
|
||||||
.replace(/<[^>]*>/g, '')
|
.replace(/<[^>]*>/g, '')
|
||||||
.replace(/ /g, ' ')
|
.replace(/ /g, ' ')
|
||||||
@@ -24,12 +26,12 @@ export function stripHtml(html: string): string {
|
|||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.trim()
|
.trim();
|
||||||
|
|
||||||
// Remove template descriptions like "Параметр X управляет категорией Y"
|
// Remove template descriptions like "Параметр X управляет категорией Y"
|
||||||
if (cleaned.match(/^Параметр .+ управляет категорией/)) {
|
if (cleaned.match(/^Параметр .+ управляет категорией/)) {
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleaned
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,99 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useBlockingStore } from '../../store/blocking'
|
import { useBlockingStore } from '../../store/blocking';
|
||||||
import { apiClient } from '../../api/client'
|
import { apiClient } from '../../api/client';
|
||||||
|
|
||||||
const CHECK_COOLDOWN_SECONDS = 5
|
const CHECK_COOLDOWN_SECONDS = 5;
|
||||||
|
|
||||||
export default function ChannelSubscriptionScreen() {
|
export default function ChannelSubscriptionScreen() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const { channelInfo, clearBlocking } = useBlockingStore()
|
const { channelInfo, clearBlocking } = useBlockingStore();
|
||||||
const [isChecking, setIsChecking] = useState(false)
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0)
|
const [cooldown, setCooldown] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Cooldown timer
|
// Cooldown timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cooldown <= 0) return
|
if (cooldown <= 0) return;
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCooldown((prev) => {
|
setCooldown((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
clearInterval(timer)
|
clearInterval(timer);
|
||||||
return 0
|
return 0;
|
||||||
}
|
}
|
||||||
return prev - 1
|
return prev - 1;
|
||||||
})
|
});
|
||||||
}, 1000)
|
}, 1000);
|
||||||
|
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer);
|
||||||
}, [cooldown])
|
}, [cooldown]);
|
||||||
|
|
||||||
const openChannel = useCallback(() => {
|
const openChannel = useCallback(() => {
|
||||||
if (channelInfo?.channel_link) {
|
if (channelInfo?.channel_link) {
|
||||||
window.open(channelInfo.channel_link, '_blank')
|
window.open(channelInfo.channel_link, '_blank');
|
||||||
}
|
}
|
||||||
}, [channelInfo?.channel_link])
|
}, [channelInfo?.channel_link]);
|
||||||
|
|
||||||
const checkSubscription = useCallback(async () => {
|
const checkSubscription = useCallback(async () => {
|
||||||
if (isChecking || cooldown > 0) return
|
if (isChecking || cooldown > 0) return;
|
||||||
|
|
||||||
setIsChecking(true)
|
setIsChecking(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make any authenticated request - if channel check passes, it will succeed
|
// Make any authenticated request - if channel check passes, it will succeed
|
||||||
await apiClient.get('/cabinet/auth/me')
|
await apiClient.get('/cabinet/auth/me');
|
||||||
// If we get here, subscription is valid - reload page
|
// If we get here, subscription is valid - reload page
|
||||||
clearBlocking()
|
clearBlocking();
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Check if it's still a channel subscription error
|
// Check if it's still a channel subscription error
|
||||||
const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } }
|
const error = err as {
|
||||||
if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') {
|
response?: { status?: number; data?: { detail?: { code?: string } } };
|
||||||
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'))
|
};
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
error.response?.data?.detail?.code === 'channel_subscription_required'
|
||||||
|
) {
|
||||||
|
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
|
||||||
} else {
|
} else {
|
||||||
// Other error - might be network issue
|
// Other error - might be network issue
|
||||||
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'))
|
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false)
|
setIsChecking(false);
|
||||||
setCooldown(CHECK_COOLDOWN_SECONDS)
|
setCooldown(CHECK_COOLDOWN_SECONDS);
|
||||||
}
|
}
|
||||||
}, [isChecking, cooldown, clearBlocking, t])
|
}, [isChecking, cooldown, clearBlocking, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
|
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md text-center">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="w-24 h-24 mx-auto rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center">
|
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
|
||||||
<svg
|
<svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-12 h-12 text-blue-400"
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">
|
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||||
{t('blocking.channel.title', 'Подписка на канал')}
|
{t('blocking.channel.title', 'Подписка на канал')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<p className="text-gray-400 mb-8 text-lg">
|
<p className="mb-8 text-lg text-gray-400">
|
||||||
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
{channelInfo?.message ||
|
||||||
|
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6">
|
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
|
||||||
<p className="text-red-400 text-sm">{error}</p>
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -101,14 +103,10 @@ export default function ChannelSubscriptionScreen() {
|
|||||||
<button
|
<button
|
||||||
onClick={openChannel}
|
onClick={openChannel}
|
||||||
disabled={!channelInfo?.channel_link}
|
disabled={!channelInfo?.channel_link}
|
||||||
className="w-full py-4 px-6 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
|
className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600"
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-5 h-5"
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
{t('blocking.channel.openChannel', 'Открыть канал')}
|
{t('blocking.channel.openChannel', 'Открыть канал')}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,27 +115,60 @@ export default function ChannelSubscriptionScreen() {
|
|||||||
<button
|
<button
|
||||||
onClick={checkSubscription}
|
onClick={checkSubscription}
|
||||||
disabled={isChecking || cooldown > 0}
|
disabled={isChecking || cooldown > 0}
|
||||||
className="w-full py-4 px-6 bg-dark-800 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
|
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isChecking ? (
|
{isChecking ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
className="h-5 w-5 animate-spin"
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{t('blocking.channel.checking', 'Проверяем...')}
|
{t('blocking.channel.checking', 'Проверяем...')}
|
||||||
</>
|
</>
|
||||||
) : cooldown > 0 ? (
|
) : cooldown > 0 ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-5 w-5 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', { seconds: cooldown })}
|
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', {
|
||||||
|
seconds: cooldown,
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
|
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
|
||||||
</>
|
</>
|
||||||
@@ -146,10 +177,10 @@ export default function ChannelSubscriptionScreen() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hint */}
|
{/* Hint */}
|
||||||
<p className="text-gray-500 text-sm mt-6">
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
|
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useBlockingStore } from '../../store/blocking'
|
import { useBlockingStore } from '../../store/blocking';
|
||||||
|
|
||||||
export default function MaintenanceScreen() {
|
export default function MaintenanceScreen() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const { maintenanceInfo } = useBlockingStore()
|
const { maintenanceInfo } = useBlockingStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
|
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="w-full max-w-md text-center">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="w-24 h-24 mx-auto rounded-full bg-dark-800 flex items-center justify-center">
|
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
|
||||||
<svg
|
<svg
|
||||||
className="w-12 h-12 text-amber-500"
|
className="h-12 w-12 text-amber-500"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -28,38 +28,49 @@ export default function MaintenanceScreen() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">
|
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||||
{t('blocking.maintenance.title', 'Технические работы')}
|
{t('blocking.maintenance.title', 'Технические работы')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<p className="text-gray-400 mb-6 text-lg">
|
<p className="mb-6 text-lg text-gray-400">
|
||||||
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')}
|
{maintenanceInfo?.message ||
|
||||||
|
t(
|
||||||
|
'blocking.maintenance.defaultMessage',
|
||||||
|
'Сервис временно недоступен. Проводятся технические работы.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Reason */}
|
{/* Reason */}
|
||||||
{maintenanceInfo?.reason && (
|
{maintenanceInfo?.reason && (
|
||||||
<div className="bg-dark-800/50 rounded-xl p-4 mb-6">
|
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
|
||||||
<p className="text-gray-500 text-sm mb-1">
|
<p className="mb-1 text-sm text-gray-500">
|
||||||
{t('blocking.maintenance.reason', 'Причина')}:
|
{t('blocking.maintenance.reason', 'Причина')}:
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-300">
|
<p className="text-gray-300">{maintenanceInfo.reason}</p>
|
||||||
{maintenanceInfo.reason}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Decorative dots */}
|
{/* Decorative dots */}
|
||||||
<div className="flex items-center justify-center gap-2 mt-8">
|
<div className="mt-8 flex items-center justify-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '0ms' }} />
|
<div
|
||||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '300ms' }} />
|
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '600ms' }} />
|
style={{ animationDelay: '0ms' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||||
|
style={{ animationDelay: '300ms' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||||
|
style={{ animationDelay: '600ms' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 text-sm mt-4">
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
|
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as MaintenanceScreen } from './MaintenanceScreen'
|
export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'
|
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
interface PageLoaderProps {
|
interface PageLoaderProps {
|
||||||
variant?: 'dark' | 'light'
|
variant?: 'dark' | 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
||||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50'
|
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50';
|
||||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500'
|
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
|
<div className={`flex min-h-screen items-center justify-center ${bgClass}`}>
|
||||||
<div className={`w-10 h-10 border-[3px] ${spinnerColor} border-t-transparent rounded-full animate-spin`} />
|
<div
|
||||||
|
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,274 +1,346 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../store/auth'
|
import { useAuthStore } from '../../store/auth';
|
||||||
import LanguageSwitcher from '../LanguageSwitcher'
|
import LanguageSwitcher from '../LanguageSwitcher';
|
||||||
import PromoDiscountBadge from '../PromoDiscountBadge'
|
import PromoDiscountBadge from '../PromoDiscountBadge';
|
||||||
import TicketNotificationBell from '../TicketNotificationBell'
|
import TicketNotificationBell from '../TicketNotificationBell';
|
||||||
import AnimatedBackground from '../AnimatedBackground'
|
import AnimatedBackground from '../AnimatedBackground';
|
||||||
import { contestsApi } from '../../api/contests'
|
import { contestsApi } from '../../api/contests';
|
||||||
import { pollsApi } from '../../api/polls'
|
import { pollsApi } from '../../api/polls';
|
||||||
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded } from '../../api/branding'
|
import {
|
||||||
import { wheelApi } from '../../api/wheel'
|
brandingApi,
|
||||||
import { themeColorsApi } from '../../api/themeColors'
|
getCachedBranding,
|
||||||
import { promoApi } from '../../api/promo'
|
setCachedBranding,
|
||||||
import { referralApi } from '../../api/referral'
|
preloadLogo,
|
||||||
import { useTheme } from '../../hooks/useTheme'
|
isLogoPreloaded,
|
||||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
} from '../../api/branding';
|
||||||
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
|
import { wheelApi } from '../../api/wheel';
|
||||||
|
import { themeColorsApi } from '../../api/themeColors';
|
||||||
|
import { promoApi } from '../../api/promo';
|
||||||
|
import { referralApi } from '../../api/referral';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp';
|
||||||
|
import { usePullToRefresh } from '../../hooks/usePullToRefresh';
|
||||||
|
|
||||||
// Fallback branding from environment variables
|
// Fallback branding from environment variables
|
||||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
||||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'
|
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icons as simple SVG components
|
// Icons as simple SVG components
|
||||||
const HomeIcon = () => (
|
const HomeIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SubscriptionIcon = () => (
|
const SubscriptionIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const WalletIcon = () => (
|
const WalletIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UsersIcon = () => (
|
const UsersIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChatIcon = () => (
|
const ChatIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UserIcon = () => (
|
const UserIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const LogoutIcon = () => (
|
const LogoutIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Theme toggle icons
|
// Theme toggle icons
|
||||||
const SunIcon = () => (
|
const SunIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const MoonIcon = () => (
|
const MoonIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const MenuIcon = () => (
|
const MenuIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const GamepadIcon = () => (
|
const GamepadIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ClipboardIcon = () => (
|
const ClipboardIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const InfoIcon = () => (
|
const InfoIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CogIcon = () => (
|
const CogIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const WheelIcon = () => (
|
const WheelIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const { user, logout, isAdmin, isAuthenticated } = useAuthStore()
|
const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false)
|
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
||||||
const { toggleTheme, isDark } = useTheme()
|
const { toggleTheme, isDark } = useTheme();
|
||||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
|
||||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||||
|
|
||||||
// Pull to refresh (disabled when mobile menu is open)
|
// Pull to refresh (disabled when mobile menu is open)
|
||||||
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
|
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
|
||||||
disabled: mobileMenuOpen,
|
disabled: mobileMenuOpen,
|
||||||
threshold: 80,
|
threshold: 80,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch enabled themes from API - same source of truth as AdminSettings
|
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||||
const { data: enabledThemes } = useQuery({
|
const { data: enabledThemes } = useQuery({
|
||||||
queryKey: ['enabled-themes'],
|
queryKey: ['enabled-themes'],
|
||||||
queryFn: themeColorsApi.getEnabledThemes,
|
queryFn: themeColorsApi.getEnabledThemes,
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
})
|
});
|
||||||
|
|
||||||
// Only show theme toggle if both themes are enabled
|
// Only show theme toggle if both themes are enabled
|
||||||
const canToggle = enabledThemes?.dark && enabledThemes?.light
|
const canToggle = enabledThemes?.dark && enabledThemes?.light;
|
||||||
|
|
||||||
// Get user photo from Telegram WebApp
|
// Get user photo from Telegram WebApp
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
const tg = (window as any).Telegram?.WebApp
|
const tg = (
|
||||||
const photoUrl = tg?.initDataUnsafe?.user?.photo_url
|
window as unknown as {
|
||||||
|
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
|
||||||
|
}
|
||||||
|
).Telegram?.WebApp;
|
||||||
|
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
|
||||||
if (photoUrl) {
|
if (photoUrl) {
|
||||||
setUserPhotoUrl(photoUrl)
|
setUserPhotoUrl(photoUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to get Telegram user photo:', e)
|
console.warn('Failed to get Telegram user photo:', e);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Lock body scroll when mobile menu is open (cross-platform)
|
// Lock body scroll when mobile menu is open (cross-platform)
|
||||||
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues
|
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues
|
||||||
// in Telegram Mini App where the menu disappears when opened from scrolled position
|
// in Telegram Mini App where the menu disappears when opened from scrolled position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mobileMenuOpen) return
|
if (!mobileMenuOpen) return;
|
||||||
|
|
||||||
const body = document.body
|
const body = document.body;
|
||||||
const html = document.documentElement
|
const html = document.documentElement;
|
||||||
|
|
||||||
// Save original styles
|
// Save original styles
|
||||||
const originalStyles = {
|
const originalStyles = {
|
||||||
bodyOverflow: body.style.overflow,
|
bodyOverflow: body.style.overflow,
|
||||||
htmlOverflow: html.style.overflow,
|
htmlOverflow: html.style.overflow,
|
||||||
}
|
};
|
||||||
|
|
||||||
// Lock scroll - simple approach without body position manipulation
|
// Lock scroll - simple approach without body position manipulation
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden';
|
||||||
html.style.overflow = 'hidden'
|
html.style.overflow = 'hidden';
|
||||||
|
|
||||||
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
|
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
|
||||||
const preventScroll = (e: TouchEvent) => {
|
const preventScroll = (e: TouchEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
// Allow scroll inside menu content
|
// Allow scroll inside menu content
|
||||||
if (target.closest('.mobile-menu-content')) return
|
if (target.closest('.mobile-menu-content')) return;
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
}
|
};
|
||||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||||
|
|
||||||
// Also prevent wheel scroll on desktop
|
// Also prevent wheel scroll on desktop
|
||||||
const preventWheel = (e: WheelEvent) => {
|
const preventWheel = (e: WheelEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('.mobile-menu-content')) return
|
if (target.closest('.mobile-menu-content')) return;
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
}
|
};
|
||||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
document.addEventListener('wheel', preventWheel, { passive: false });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Restore original styles
|
// Restore original styles
|
||||||
body.style.overflow = originalStyles.bodyOverflow
|
body.style.overflow = originalStyles.bodyOverflow;
|
||||||
html.style.overflow = originalStyles.htmlOverflow
|
html.style.overflow = originalStyles.htmlOverflow;
|
||||||
|
|
||||||
// Remove listeners
|
// Remove listeners
|
||||||
document.removeEventListener('touchmove', preventScroll)
|
document.removeEventListener('touchmove', preventScroll);
|
||||||
document.removeEventListener('wheel', preventWheel)
|
document.removeEventListener('wheel', preventWheel);
|
||||||
}
|
};
|
||||||
}, [mobileMenuOpen])
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
// Detect virtual keyboard by tracking focus on input elements
|
// Detect virtual keyboard by tracking focus on input elements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocusIn = (e: FocusEvent) => {
|
const handleFocusIn = (e: FocusEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
setIsKeyboardOpen(true)
|
setIsKeyboardOpen(true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFocusOut = (e: FocusEvent) => {
|
const handleFocusOut = (e: FocusEvent) => {
|
||||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||||
// Only close if not focusing another input
|
// Only close if not focusing another input
|
||||||
if (!relatedTarget ||
|
if (
|
||||||
|
!relatedTarget ||
|
||||||
(relatedTarget.tagName !== 'INPUT' &&
|
(relatedTarget.tagName !== 'INPUT' &&
|
||||||
relatedTarget.tagName !== 'TEXTAREA' &&
|
relatedTarget.tagName !== 'TEXTAREA' &&
|
||||||
!relatedTarget.isContentEditable)) {
|
!relatedTarget.isContentEditable)
|
||||||
setIsKeyboardOpen(false)
|
) {
|
||||||
}
|
setIsKeyboardOpen(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener('focusin', handleFocusIn)
|
document.addEventListener('focusin', handleFocusIn);
|
||||||
document.addEventListener('focusout', handleFocusOut)
|
document.addEventListener('focusout', handleFocusOut);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('focusin', handleFocusIn)
|
document.removeEventListener('focusin', handleFocusIn);
|
||||||
document.removeEventListener('focusout', handleFocusOut)
|
document.removeEventListener('focusout', handleFocusOut);
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// State to track if logo image has loaded - start with true if already preloaded
|
// State to track if logo image has loaded - start with true if already preloaded
|
||||||
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded())
|
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
|
||||||
|
|
||||||
// Fetch branding settings with localStorage cache for instant load
|
// Fetch branding settings with localStorage cache for instant load
|
||||||
const { data: branding } = useQuery({
|
const { data: branding } = useQuery({
|
||||||
queryKey: ['branding'],
|
queryKey: ['branding'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await brandingApi.getBranding()
|
const data = await brandingApi.getBranding();
|
||||||
setCachedBranding(data) // Update cache
|
setCachedBranding(data); // Update cache
|
||||||
// Preload logo in background
|
// Preload logo in background
|
||||||
preloadLogo(data)
|
preloadLogo(data);
|
||||||
return data
|
return data;
|
||||||
},
|
},
|
||||||
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
|
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
|
||||||
staleTime: 60000, // 1 minute
|
staleTime: 60000, // 1 minute
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Computed branding values - use fallback only if no branding and no cache
|
// Computed branding values - use fallback only if no branding and no cache
|
||||||
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode)
|
const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
|
||||||
const logoLetter = branding?.logo_letter || FALLBACK_LOGO
|
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
|
||||||
const hasCustomLogo = branding?.has_custom_logo || false
|
const hasCustomLogo = branding?.has_custom_logo || false;
|
||||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||||
|
|
||||||
// Set document title
|
// Set document title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = appName || 'VPN' // Fallback title if name is empty
|
document.title = appName || 'VPN'; // Fallback title if name is empty
|
||||||
}, [appName])
|
}, [appName]);
|
||||||
|
|
||||||
// Fetch contests and polls counts to determine if they should be shown
|
// Fetch contests and polls counts to determine if they should be shown
|
||||||
const { data: contestsCount } = useQuery({
|
const { data: contestsCount } = useQuery({
|
||||||
@@ -277,7 +349,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 60000, // 1 minute
|
staleTime: 60000, // 1 minute
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: pollsCount } = useQuery({
|
const { data: pollsCount } = useQuery({
|
||||||
queryKey: ['polls-count'],
|
queryKey: ['polls-count'],
|
||||||
@@ -285,7 +357,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 60000, // 1 minute
|
staleTime: 60000, // 1 minute
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch wheel config to check if enabled
|
// Fetch wheel config to check if enabled
|
||||||
const { data: wheelConfig } = useQuery({
|
const { data: wheelConfig } = useQuery({
|
||||||
@@ -294,7 +366,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 60000, // 1 minute
|
staleTime: 60000, // 1 minute
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch referral terms to check if enabled
|
// Fetch referral terms to check if enabled
|
||||||
const { data: referralTerms } = useQuery({
|
const { data: referralTerms } = useQuery({
|
||||||
@@ -303,7 +375,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 60000, // 1 minute
|
staleTime: 60000, // 1 minute
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch active discount to determine mobile layout
|
// Fetch active discount to determine mobile layout
|
||||||
const { data: activeDiscount } = useQuery({
|
const { data: activeDiscount } = useQuery({
|
||||||
@@ -311,86 +383,90 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
queryFn: promoApi.getActiveDiscount,
|
queryFn: promoApi.getActiveDiscount,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Check if promo is active (to hide language switcher on mobile)
|
// Check if promo is active (to hide language switcher on mobile)
|
||||||
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent
|
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
|
||||||
|
|
||||||
const navItems = useMemo(() => {
|
const navItems = useMemo(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||||
]
|
];
|
||||||
|
|
||||||
// Only show referral if program is enabled
|
// Only show referral if program is enabled
|
||||||
if (referralTerms?.is_enabled) {
|
if (referralTerms?.is_enabled) {
|
||||||
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon })
|
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon });
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon })
|
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon });
|
||||||
|
|
||||||
// Only show contests if there are available contests
|
// Only show contests if there are available contests
|
||||||
if (contestsCount && contestsCount.count > 0) {
|
if (contestsCount && contestsCount.count > 0) {
|
||||||
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon })
|
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show polls if there are available polls
|
// Only show polls if there are available polls
|
||||||
if (pollsCount && pollsCount.count > 0) {
|
if (pollsCount && pollsCount.count > 0) {
|
||||||
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon })
|
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon });
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon })
|
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon });
|
||||||
|
|
||||||
return items
|
return items;
|
||||||
}, [t, contestsCount, pollsCount, referralTerms])
|
}, [t, contestsCount, pollsCount, referralTerms]);
|
||||||
|
|
||||||
// Separate navItems for desktop that includes wheel (if enabled)
|
// Separate navItems for desktop that includes wheel (if enabled)
|
||||||
const desktopNavItems = useMemo(() => {
|
const desktopNavItems = useMemo(() => {
|
||||||
const items = [...navItems]
|
const items = [...navItems];
|
||||||
// Add wheel before info if enabled
|
// Add wheel before info if enabled
|
||||||
if (wheelConfig?.is_enabled) {
|
if (wheelConfig?.is_enabled) {
|
||||||
const infoIndex = items.findIndex(item => item.path === '/info')
|
const infoIndex = items.findIndex((item) => item.path === '/info');
|
||||||
if (infoIndex !== -1) {
|
if (infoIndex !== -1) {
|
||||||
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
|
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
|
||||||
} else {
|
} else {
|
||||||
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
|
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items
|
return items;
|
||||||
}, [navItems, wheelConfig, t])
|
}, [navItems, wheelConfig, t]);
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
|
||||||
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
|
|
||||||
]
|
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path
|
const isActive = (path: string) => location.pathname === path;
|
||||||
const isAdminActive = () => location.pathname.startsWith('/admin')
|
const isAdminActive = () => location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Animated Background */}
|
{/* Animated Background */}
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
|
|
||||||
{/* Pull to refresh indicator */}
|
{/* Pull to refresh indicator */}
|
||||||
{(isPulling || isRefreshing) && (
|
{(isPulling || isRefreshing) && (
|
||||||
<div
|
<div
|
||||||
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200"
|
className="fixed left-1/2 z-[100] flex -translate-x-1/2 items-center justify-center transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
|
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
|
||||||
opacity: isRefreshing ? 1 : progress,
|
opacity: isRefreshing ? 1 : progress,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}>
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dark-700 bg-dark-800 shadow-lg ${isRefreshing ? 'animate-pulse' : ''}`}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
|
className={`h-5 w-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
|
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,19 +474,30 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header
|
<header
|
||||||
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10"
|
className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10"
|
||||||
style={{
|
style={{
|
||||||
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
||||||
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
|
paddingTop: isFullscreen
|
||||||
|
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}>
|
<div
|
||||||
<div className="flex justify-between items-center h-16 lg:h-20">
|
className="mx-auto w-full px-4 sm:px-6"
|
||||||
|
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center justify-between lg:h-20">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
<Link
|
||||||
<div className="w-10 h-10 sm:w-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative">
|
to="/"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md dark:bg-dark-800/80 sm:h-11 sm:w-11 lg:h-12 lg:w-12">
|
||||||
{/* Always show letter as fallback */}
|
{/* Always show letter as fallback */}
|
||||||
<span className={`text-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
<span
|
||||||
|
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 sm:text-xl ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
|
||||||
|
>
|
||||||
{logoLetter}
|
{logoLetter}
|
||||||
</span>
|
</span>
|
||||||
{/* Logo image with smooth fade-in */}
|
{/* Logo image with smooth fade-in */}
|
||||||
@@ -418,28 +505,28 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt={appName || 'Logo'}
|
alt={appName || 'Logo'}
|
||||||
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||||
onLoad={() => setLogoLoaded(true)}
|
onLoad={() => setLogoLoaded(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{appName && (
|
{appName && (
|
||||||
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap">
|
<span className="whitespace-nowrap text-base font-semibold text-dark-100 lg:text-lg">
|
||||||
{appName}
|
{appName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden lg:flex items-center gap-1">
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
{desktopNavItems.map((item) => (
|
{desktopNavItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
|
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||||
isActive(item.path)
|
isActive(item.path)
|
||||||
? 'text-accent-400 bg-accent-500/10'
|
? 'bg-accent-500/10 text-accent-400'
|
||||||
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50'
|
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
@@ -448,15 +535,15 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
))}
|
))}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-6 bg-dark-700 mx-2" />
|
<div className="mx-2 h-6 w-px bg-dark-700" />
|
||||||
{adminNavItems.map((item) => (
|
{adminNavItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
|
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||||
isAdminActive()
|
isAdminActive()
|
||||||
? 'text-warning-400 bg-warning-500/10'
|
? 'bg-warning-500/10 text-warning-400'
|
||||||
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10'
|
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
@@ -473,21 +560,26 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
{canToggle && (
|
{canToggle && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toggleTheme()
|
toggleTheme();
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className="relative p-2 rounded-xl transition-all duration-200
|
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-champagne-500 transition-all duration-200 hover:bg-dark-700 hover:text-champagne-800 dark:text-dark-400 dark:hover:text-accent-400"
|
||||||
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
|
|
||||||
dark:text-dark-400 dark:hover:text-accent-400
|
|
||||||
text-champagne-500 hover:text-champagne-800"
|
|
||||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||||
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
|
aria-label={
|
||||||
|
isDark
|
||||||
|
? t('theme.light') || 'Switch to light mode'
|
||||||
|
: t('theme.dark') || 'Switch to dark mode'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative h-5 w-5">
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 transition-all duration-300 ${isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'}`}
|
||||||
>
|
>
|
||||||
<div className="relative w-5 h-5">
|
|
||||||
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
|
|
||||||
<MoonIcon />
|
<MoonIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}>
|
<div
|
||||||
|
className={`absolute inset-0 transition-all duration-300 ${isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100'}`}
|
||||||
|
>
|
||||||
<SunIcon />
|
<SunIcon />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -501,17 +593,20 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<TicketNotificationBell isAdmin={isAdminActive()} />
|
<TicketNotificationBell isAdmin={isAdminActive()} />
|
||||||
</div>
|
</div>
|
||||||
{/* Hide language switcher on mobile when promo is active */}
|
{/* Hide language switcher on mobile when promo is active */}
|
||||||
<div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}>
|
<div
|
||||||
|
className={isPromoActive ? 'hidden sm:block' : ''}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile - Desktop */}
|
{/* Profile - Desktop */}
|
||||||
<div className="hidden sm:flex items-center gap-3">
|
<div className="hidden items-center gap-3 sm:flex">
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors"
|
className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors hover:bg-dark-800/50"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-dark-300">
|
<span className="text-sm text-dark-300">
|
||||||
@@ -531,11 +626,13 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setMobileMenuOpen(!mobileMenuOpen)
|
setMobileMenuOpen(!mobileMenuOpen);
|
||||||
}}
|
}}
|
||||||
className="lg:hidden btn-icon"
|
className="btn-icon lg:hidden"
|
||||||
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
|
aria-label={
|
||||||
|
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
|
||||||
|
}
|
||||||
aria-expanded={mobileMenuOpen}
|
aria-expanded={mobileMenuOpen}
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||||
@@ -543,7 +640,6 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Spacer for fixed header - matches header height */}
|
{/* Spacer for fixed header - matches header height */}
|
||||||
@@ -553,17 +649,17 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
|
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-shrink-0 h-16 lg:h-20" />
|
<div className="h-16 flex-shrink-0 lg:h-20" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile menu - fixed overlay below header */}
|
{/* Mobile menu - fixed overlay below header */}
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div
|
<div
|
||||||
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in"
|
className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
|
||||||
style={{
|
style={{
|
||||||
top: isFullscreen
|
top: isFullscreen
|
||||||
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
||||||
: '64px'
|
: '64px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
@@ -573,23 +669,28 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Menu content */}
|
{/* Menu content */}
|
||||||
<div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}>
|
<div
|
||||||
<div className="max-w-6xl mx-auto px-4 py-4">
|
className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-4">
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
|
<div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userPhotoUrl ? (
|
{userPhotoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={userPhotoUrl}
|
src={userPhotoUrl}
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
className="w-10 h-10 rounded-full object-cover"
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
e.currentTarget.style.display = 'none'
|
e.currentTarget.style.display = 'none';
|
||||||
e.currentTarget.nextElementSibling?.classList.remove('hidden')
|
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-full bg-dark-700 ${userPhotoUrl ? 'hidden' : ''}`}
|
||||||
|
>
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -622,7 +723,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="divider my-3" />
|
<div className="divider my-3" />
|
||||||
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider">
|
<div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
{t('admin.nav.title')}
|
{t('admin.nav.title')}
|
||||||
</div>
|
</div>
|
||||||
{adminNavItems.map((item) => (
|
{adminNavItems.map((item) => (
|
||||||
@@ -630,7 +731,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`}
|
className={`nav-item ${isAdminActive() ? 'bg-warning-500/10 text-warning-400' : 'text-warning-500/70'}`}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -652,8 +753,8 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false);
|
||||||
logout()
|
logout();
|
||||||
}}
|
}}
|
||||||
className="nav-item w-full text-error-400"
|
className="nav-item w-full text-error-400"
|
||||||
>
|
>
|
||||||
@@ -667,19 +768,20 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8">
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:px-6 lg:pb-8">
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
|
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
|
||||||
<nav className={`bottom-nav lg:hidden transition-opacity duration-200 ${isKeyboardOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
<nav
|
||||||
|
className={`bottom-nav transition-opacity duration-200 lg:hidden ${isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||||
|
>
|
||||||
<div className="flex justify-around">
|
<div className="flex justify-around">
|
||||||
{navItems.filter(item =>
|
{navItems
|
||||||
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path)
|
.filter((item) =>
|
||||||
).map((item) => (
|
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
|
||||||
|
)
|
||||||
|
.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
@@ -687,11 +789,11 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
|
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span>
|
<span className="mt-1 whitespace-nowrap text-2xs">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import { forwardRef } from 'react'
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl'
|
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
interface BentoCardBaseProps {
|
interface BentoCardBaseProps {
|
||||||
size?: BentoSize
|
size?: BentoSize;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
className?: string
|
className?: string;
|
||||||
hover?: boolean
|
hover?: boolean;
|
||||||
glow?: boolean
|
glow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BentoCardDivProps extends BentoCardBaseProps {
|
interface BentoCardDivProps extends BentoCardBaseProps {
|
||||||
as?: 'div'
|
as?: 'div';
|
||||||
onClick?: () => void
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BentoCardLinkProps extends BentoCardBaseProps {
|
interface BentoCardLinkProps extends BentoCardBaseProps {
|
||||||
as: 'link'
|
as: 'link';
|
||||||
to: string
|
to: string;
|
||||||
state?: unknown
|
state?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BentoCardButtonProps extends BentoCardBaseProps {
|
interface BentoCardButtonProps extends BentoCardBaseProps {
|
||||||
as: 'button'
|
as: 'button';
|
||||||
onClick?: () => void
|
onClick?: () => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
type?: 'button' | 'submit'
|
type?: 'button' | 'submit';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps
|
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps;
|
||||||
|
|
||||||
const sizeClasses: Record<BentoSize, string> = {
|
const sizeClasses: Record<BentoSize, string> = {
|
||||||
sm: '',
|
sm: '',
|
||||||
md: 'col-span-2',
|
md: 'col-span-2',
|
||||||
lg: 'row-span-2',
|
lg: 'row-span-2',
|
||||||
xl: 'col-span-2 row-span-2',
|
xl: 'col-span-2 row-span-2',
|
||||||
}
|
};
|
||||||
|
|
||||||
const baseClasses = `
|
const baseClasses = `
|
||||||
bento-card
|
bento-card
|
||||||
@@ -45,7 +45,7 @@ const baseClasses = `
|
|||||||
bg-dark-900/70
|
bg-dark-900/70
|
||||||
border border-dark-700/40
|
border border-dark-700/40
|
||||||
transition-all duration-300 ease-smooth
|
transition-all duration-300 ease-smooth
|
||||||
`
|
`;
|
||||||
|
|
||||||
const hoverClasses = `
|
const hoverClasses = `
|
||||||
cursor-pointer
|
cursor-pointer
|
||||||
@@ -54,21 +54,15 @@ const hoverClasses = `
|
|||||||
hover:shadow-lg
|
hover:shadow-lg
|
||||||
hover:scale-[1.01]
|
hover:scale-[1.01]
|
||||||
active:scale-[0.99]
|
active:scale-[0.99]
|
||||||
`
|
`;
|
||||||
|
|
||||||
const glowClasses = `
|
const glowClasses = `
|
||||||
hover:shadow-glow
|
hover:shadow-glow
|
||||||
hover:border-accent-500/30
|
hover:border-accent-500/30
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
||||||
const {
|
const { size = 'sm', children, className = '', hover = false, glow = false } = props;
|
||||||
size = 'sm',
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
hover = false,
|
|
||||||
glow = false,
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
baseClasses,
|
baseClasses,
|
||||||
@@ -76,19 +70,21 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
|||||||
hover && hoverClasses,
|
hover && hoverClasses,
|
||||||
glow && glowClasses,
|
glow && glowClasses,
|
||||||
className,
|
className,
|
||||||
].filter(Boolean).join(' ')
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
if (props.as === 'link') {
|
if (props.as === 'link') {
|
||||||
const { to, state } = props as BentoCardLinkProps
|
const { to, state } = props as BentoCardLinkProps;
|
||||||
return (
|
return (
|
||||||
<Link to={to} state={state} className={classes}>
|
<Link to={to} state={state} className={classes}>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.as === 'button') {
|
if (props.as === 'button') {
|
||||||
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps
|
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
@@ -99,17 +95,17 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { onClick } = props as BentoCardDivProps
|
const { onClick } = props as BentoCardDivProps;
|
||||||
return (
|
return (
|
||||||
<div ref={ref} onClick={onClick} className={classes}>
|
<div ref={ref} onClick={onClick} className={classes}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
BentoCard.displayName = 'BentoCard'
|
BentoCard.displayName = 'BentoCard';
|
||||||
|
|
||||||
export default BentoCard
|
export default BentoCard;
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
interface BentoSkeletonProps {
|
interface BentoSkeletonProps {
|
||||||
className?: string
|
className?: string;
|
||||||
count?: number
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
|
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
|
||||||
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`
|
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`;
|
||||||
|
|
||||||
if (count > 1) {
|
if (count > 1) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<div
|
<div key={i} className={baseClasses} style={{ '--stagger': i } as React.CSSProperties} />
|
||||||
key={i}
|
|
||||||
className={baseClasses}
|
|
||||||
style={{ '--stagger': i } as React.CSSProperties}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <div className={baseClasses} />;
|
||||||
<div
|
|
||||||
className={baseClasses}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, memo } from 'react'
|
import { useEffect, useRef, useState, useMemo, memo } from 'react';
|
||||||
import type { WheelPrize } from '../../api/wheel'
|
import type { WheelPrize } from '../../api/wheel';
|
||||||
|
|
||||||
interface FortuneWheelProps {
|
interface FortuneWheelProps {
|
||||||
prizes: WheelPrize[]
|
prizes: WheelPrize[];
|
||||||
isSpinning: boolean
|
isSpinning: boolean;
|
||||||
targetRotation: number | null
|
targetRotation: number | null;
|
||||||
onSpinComplete: () => void
|
onSpinComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-generate sparkle positions to avoid recalculating on each render
|
// Pre-generate sparkle positions to avoid recalculating on each render
|
||||||
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
|
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
|
||||||
top: `${20 + (i * 10) % 60}%`,
|
top: `${20 + ((i * 10) % 60)}%`,
|
||||||
left: `${15 + (i * 13) % 70}%`,
|
left: `${15 + ((i * 13) % 70)}%`,
|
||||||
delay: `${i * 0.15}s`,
|
delay: `${i * 0.15}s`,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const FortuneWheel = memo(function FortuneWheel({
|
const FortuneWheel = memo(function FortuneWheel({
|
||||||
prizes,
|
prizes,
|
||||||
@@ -21,141 +21,150 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
targetRotation,
|
targetRotation,
|
||||||
onSpinComplete,
|
onSpinComplete,
|
||||||
}: FortuneWheelProps) {
|
}: FortuneWheelProps) {
|
||||||
const wheelRef = useRef<SVGGElement>(null)
|
const wheelRef = useRef<SVGGElement>(null);
|
||||||
const [currentRotation, setCurrentRotation] = useState(0)
|
const [currentRotation, setCurrentRotation] = useState(0);
|
||||||
const [lightPhase, setLightPhase] = useState(0)
|
const [lightPhase, setLightPhase] = useState(0);
|
||||||
|
|
||||||
// Animated lights effect - use phase instead of random array (less re-renders)
|
// Animated lights effect - use phase instead of random array (less re-renders)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSpinning) {
|
if (isSpinning) {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
|
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
||||||
}, 250) // Slower interval = better performance
|
}, 250); // Slower interval = better performance
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval);
|
||||||
} else {
|
} else {
|
||||||
setLightPhase(0)
|
setLightPhase(0);
|
||||||
}
|
}
|
||||||
}, [isSpinning])
|
}, [isSpinning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
||||||
setCurrentRotation(targetRotation)
|
setCurrentRotation(targetRotation);
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
onSpinComplete()
|
onSpinComplete();
|
||||||
}, 5000)
|
}, 5000);
|
||||||
|
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [isSpinning, targetRotation, onSpinComplete])
|
}, [isSpinning, targetRotation, onSpinComplete]);
|
||||||
|
|
||||||
// Memoize light pattern calculation
|
// Memoize light pattern calculation
|
||||||
const lightPattern = useMemo(() => {
|
const lightPattern = useMemo(() => {
|
||||||
return Array.from({ length: 20 }, (_, i) => {
|
return Array.from({ length: 20 }, (_, i) => {
|
||||||
if (!isSpinning) return i % 2 === 0
|
if (!isSpinning) return i % 2 === 0;
|
||||||
return (i + lightPhase) % 3 !== 0
|
return (i + lightPhase) % 3 !== 0;
|
||||||
})
|
});
|
||||||
}, [isSpinning, lightPhase])
|
}, [isSpinning, lightPhase]);
|
||||||
|
|
||||||
if (prizes.length === 0) {
|
if (prizes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
|
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
|
||||||
<p className="text-dark-400">No prizes configured</p>
|
<p className="text-dark-400">No prizes configured</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = 400
|
const size = 400;
|
||||||
const center = size / 2
|
const center = size / 2;
|
||||||
const outerRadius = size / 2 - 20
|
const outerRadius = size / 2 - 20;
|
||||||
const innerRadius = outerRadius - 15
|
const innerRadius = outerRadius - 15;
|
||||||
const prizeRadius = innerRadius - 5
|
const prizeRadius = innerRadius - 5;
|
||||||
const sectorAngle = 360 / prizes.length
|
const sectorAngle = 360 / prizes.length;
|
||||||
const hubRadius = 45
|
const hubRadius = 45;
|
||||||
|
|
||||||
const createSectorPath = (index: number) => {
|
const createSectorPath = (index: number) => {
|
||||||
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180)
|
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180);
|
||||||
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180)
|
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180);
|
||||||
|
|
||||||
const x1 = center + prizeRadius * Math.cos(startAngle)
|
const x1 = center + prizeRadius * Math.cos(startAngle);
|
||||||
const y1 = center + prizeRadius * Math.sin(startAngle)
|
const y1 = center + prizeRadius * Math.sin(startAngle);
|
||||||
const x2 = center + prizeRadius * Math.cos(endAngle)
|
const x2 = center + prizeRadius * Math.cos(endAngle);
|
||||||
const y2 = center + prizeRadius * Math.sin(endAngle)
|
const y2 = center + prizeRadius * Math.sin(endAngle);
|
||||||
|
|
||||||
const x1Inner = center + hubRadius * Math.cos(startAngle)
|
const x1Inner = center + hubRadius * Math.cos(startAngle);
|
||||||
const y1Inner = center + hubRadius * Math.sin(startAngle)
|
const y1Inner = center + hubRadius * Math.sin(startAngle);
|
||||||
const x2Inner = center + hubRadius * Math.cos(endAngle)
|
const x2Inner = center + hubRadius * Math.cos(endAngle);
|
||||||
const y2Inner = center + hubRadius * Math.sin(endAngle)
|
const y2Inner = center + hubRadius * Math.sin(endAngle);
|
||||||
|
|
||||||
const largeArc = sectorAngle > 180 ? 1 : 0
|
const largeArc = sectorAngle > 180 ? 1 : 0;
|
||||||
|
|
||||||
return `M ${x1Inner} ${y1Inner}
|
return `M ${x1Inner} ${y1Inner}
|
||||||
L ${x1} ${y1}
|
L ${x1} ${y1}
|
||||||
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
|
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
|
||||||
L ${x2Inner} ${y2Inner}
|
L ${x2Inner} ${y2Inner}
|
||||||
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`
|
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Position for emoji - closer to outer edge
|
// Position for emoji - closer to outer edge
|
||||||
const getEmojiPosition = (index: number) => {
|
const getEmojiPosition = (index: number) => {
|
||||||
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
|
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
||||||
const emojiRadius = prizeRadius * 0.75
|
const emojiRadius = prizeRadius * 0.75;
|
||||||
return {
|
return {
|
||||||
x: center + emojiRadius * Math.cos(angle),
|
x: center + emojiRadius * Math.cos(angle),
|
||||||
y: center + emojiRadius * Math.sin(angle),
|
y: center + emojiRadius * Math.sin(angle),
|
||||||
rotation: index * sectorAngle + sectorAngle / 2,
|
rotation: index * sectorAngle + sectorAngle / 2,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Position for text - between hub and emoji
|
// Position for text - between hub and emoji
|
||||||
const getTextPosition = (index: number) => {
|
const getTextPosition = (index: number) => {
|
||||||
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
|
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
||||||
const textRadius = prizeRadius * 0.45
|
const textRadius = prizeRadius * 0.45;
|
||||||
return {
|
return {
|
||||||
x: center + textRadius * Math.cos(angle),
|
x: center + textRadius * Math.cos(angle),
|
||||||
y: center + textRadius * Math.sin(angle),
|
y: center + textRadius * Math.sin(angle),
|
||||||
rotation: index * sectorAngle + sectorAngle / 2,
|
rotation: index * sectorAngle + sectorAngle / 2,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
// Alternate colors for sectors
|
// Alternate colors for sectors
|
||||||
const getSectorColors = (index: number, baseColor?: string) => {
|
const getSectorColors = (index: number, baseColor?: string) => {
|
||||||
if (baseColor) return baseColor
|
if (baseColor) return baseColor;
|
||||||
const colors = [
|
const colors = [
|
||||||
'#8B5CF6', '#EC4899', '#3B82F6', '#10B981',
|
'#8B5CF6',
|
||||||
'#F59E0B', '#EF4444', '#6366F1', '#14B8A6'
|
'#EC4899',
|
||||||
]
|
'#3B82F6',
|
||||||
return colors[index % colors.length]
|
'#10B981',
|
||||||
}
|
'#F59E0B',
|
||||||
|
'#EF4444',
|
||||||
|
'#6366F1',
|
||||||
|
'#14B8A6',
|
||||||
|
];
|
||||||
|
return colors[index % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
// Truncate text intelligently
|
// Truncate text intelligently
|
||||||
const truncateText = (text: string, maxLen: number) => {
|
const truncateText = (text: string, maxLen: number) => {
|
||||||
if (text.length <= maxLen) return text
|
if (text.length <= maxLen) return text;
|
||||||
return text.substring(0, maxLen - 1) + '..'
|
return text.substring(0, maxLen - 1) + '..';
|
||||||
}
|
};
|
||||||
|
|
||||||
// Calculate max text length based on number of sectors
|
// Calculate max text length based on number of sectors
|
||||||
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8
|
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full max-w-[380px] mx-auto select-none">
|
<div className="relative mx-auto w-full max-w-[380px] select-none">
|
||||||
{/* Outer glow effect */}
|
{/* Outer glow effect */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
|
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
|
||||||
isSpinning ? 'opacity-100 scale-105' : 'opacity-60'
|
isSpinning ? 'scale-105 opacity-100' : 'opacity-60'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
|
background:
|
||||||
|
'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
|
||||||
filter: 'blur(25px)',
|
filter: 'blur(25px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pointer */}
|
{/* Pointer */}
|
||||||
<div className="absolute top-[-12px] left-1/2 -translate-x-1/2 z-20">
|
<div className="absolute left-1/2 top-[-12px] z-20 -translate-x-1/2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
|
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
|
||||||
style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)' }}
|
style={{
|
||||||
|
background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
|
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
|
||||||
<defs>
|
<defs>
|
||||||
@@ -166,7 +175,13 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
<stop offset="100%" stopColor="#D97706" />
|
<stop offset="100%" stopColor="#D97706" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="pointerGlow">
|
<filter id="pointerGlow">
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#F59E0B" floodOpacity="0.6"/>
|
<feDropShadow
|
||||||
|
dx="0"
|
||||||
|
dy="2"
|
||||||
|
stdDeviation="3"
|
||||||
|
floodColor="#F59E0B"
|
||||||
|
floodOpacity="0.6"
|
||||||
|
/>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<polygon
|
<polygon
|
||||||
@@ -174,35 +189,35 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
fill="url(#pointerGold)"
|
fill="url(#pointerGold)"
|
||||||
filter="url(#pointerGlow)"
|
filter="url(#pointerGlow)"
|
||||||
/>
|
/>
|
||||||
<polygon
|
<polygon points="22,50 6,16 22,4" fill="rgba(255,255,255,0.3)" />
|
||||||
points="22,50 6,16 22,4"
|
<circle cx="22" cy="24" r="8" fill="#FEF3C7" />
|
||||||
fill="rgba(255,255,255,0.3)"
|
<circle cx="22" cy="24" r="5" fill="#FBBF24" />
|
||||||
/>
|
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8" />
|
||||||
<circle cx="22" cy="24" r="8" fill="#FEF3C7"/>
|
|
||||||
<circle cx="22" cy="24" r="5" fill="#FBBF24"/>
|
|
||||||
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Wheel */}
|
{/* Main Wheel */}
|
||||||
<div className="relative aspect-square">
|
<div className="relative aspect-square">
|
||||||
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full">
|
<svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full">
|
||||||
<defs>
|
<defs>
|
||||||
{/* Sector gradients */}
|
{/* Sector gradients */}
|
||||||
{prizes.map((prize, index) => {
|
{prizes.map((prize, index) => {
|
||||||
const color = getSectorColors(index, prize.color)
|
const color = getSectorColors(index, prize.color);
|
||||||
return (
|
return (
|
||||||
<linearGradient
|
<linearGradient
|
||||||
key={`grad-${index}`}
|
key={`grad-${index}`}
|
||||||
id={`sectorGrad-${index}`}
|
id={`sectorGrad-${index}`}
|
||||||
x1="0%" y1="0%" x2="100%" y2="100%"
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="100%"
|
||||||
>
|
>
|
||||||
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
||||||
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
|
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
|
||||||
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
|
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Outer ring gradient */}
|
{/* Outer ring gradient */}
|
||||||
@@ -223,7 +238,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
|
|
||||||
{/* Text shadow filter */}
|
{/* Text shadow filter */}
|
||||||
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7"/>
|
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7" />
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -252,10 +267,10 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
|
|
||||||
{/* LED lights on outer ring */}
|
{/* LED lights on outer ring */}
|
||||||
{Array.from({ length: 20 }).map((_, i) => {
|
{Array.from({ length: 20 }).map((_, i) => {
|
||||||
const angle = (i * 18 - 90) * (Math.PI / 180)
|
const angle = (i * 18 - 90) * (Math.PI / 180);
|
||||||
const dotX = center + outerRadius * Math.cos(angle)
|
const dotX = center + outerRadius * Math.cos(angle);
|
||||||
const dotY = center + outerRadius * Math.sin(angle)
|
const dotY = center + outerRadius * Math.sin(angle);
|
||||||
const isLit = lightPattern[i] ?? (i % 2 === 0)
|
const isLit = lightPattern[i] ?? i % 2 === 0;
|
||||||
return (
|
return (
|
||||||
<g key={`led-${i}`}>
|
<g key={`led-${i}`}>
|
||||||
{isLit && (
|
{isLit && (
|
||||||
@@ -277,7 +292,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Rotating wheel group */}
|
{/* Rotating wheel group */}
|
||||||
@@ -286,9 +301,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
style={{
|
style={{
|
||||||
transformOrigin: `${center}px ${center}px`,
|
transformOrigin: `${center}px ${center}px`,
|
||||||
transform: `rotate(${currentRotation}deg)`,
|
transform: `rotate(${currentRotation}deg)`,
|
||||||
transition: isSpinning
|
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
|
||||||
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
|
|
||||||
: 'none',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Sectors */}
|
{/* Sectors */}
|
||||||
@@ -304,11 +317,11 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
|
|
||||||
{/* Sector dividers */}
|
{/* Sector dividers */}
|
||||||
{prizes.map((_, index) => {
|
{prizes.map((_, index) => {
|
||||||
const angle = (index * sectorAngle - 90) * (Math.PI / 180)
|
const angle = (index * sectorAngle - 90) * (Math.PI / 180);
|
||||||
const x1 = center + hubRadius * Math.cos(angle)
|
const x1 = center + hubRadius * Math.cos(angle);
|
||||||
const y1 = center + hubRadius * Math.sin(angle)
|
const y1 = center + hubRadius * Math.sin(angle);
|
||||||
const x2 = center + prizeRadius * Math.cos(angle)
|
const x2 = center + prizeRadius * Math.cos(angle);
|
||||||
const y2 = center + prizeRadius * Math.sin(angle)
|
const y2 = center + prizeRadius * Math.sin(angle);
|
||||||
return (
|
return (
|
||||||
<line
|
<line
|
||||||
key={`divider-${index}`}
|
key={`divider-${index}`}
|
||||||
@@ -319,12 +332,12 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
stroke="rgba(255,255,255,0.25)"
|
stroke="rgba(255,255,255,0.25)"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Prize content - Emoji */}
|
{/* Prize content - Emoji */}
|
||||||
{prizes.map((prize, index) => {
|
{prizes.map((prize, index) => {
|
||||||
const pos = getEmojiPosition(index)
|
const pos = getEmojiPosition(index);
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
key={`emoji-${prize.id}`}
|
key={`emoji-${prize.id}`}
|
||||||
@@ -332,19 +345,19 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
y={pos.y}
|
y={pos.y}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
fontSize={prizes.length <= 6 ? "32" : "26"}
|
fontSize={prizes.length <= 6 ? '32' : '26'}
|
||||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
||||||
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
|
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
|
||||||
>
|
>
|
||||||
{prize.emoji}
|
{prize.emoji}
|
||||||
</text>
|
</text>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Prize content - Text */}
|
{/* Prize content - Text */}
|
||||||
{prizes.map((prize, index) => {
|
{prizes.map((prize, index) => {
|
||||||
const pos = getTextPosition(index)
|
const pos = getTextPosition(index);
|
||||||
const displayText = truncateText(prize.display_name, maxTextLength)
|
const displayText = truncateText(prize.display_name, maxTextLength);
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
key={`text-${prize.id}`}
|
key={`text-${prize.id}`}
|
||||||
@@ -352,7 +365,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
y={pos.y}
|
y={pos.y}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
fontSize={prizes.length <= 4 ? "13" : prizes.length <= 6 ? "11" : "9"}
|
fontSize={prizes.length <= 4 ? '13' : prizes.length <= 6 ? '11' : '9'}
|
||||||
fontWeight="700"
|
fontWeight="700"
|
||||||
fill="#FFFFFF"
|
fill="#FFFFFF"
|
||||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
||||||
@@ -361,7 +374,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
>
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</text>
|
</text>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
@@ -386,13 +399,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hub shine */}
|
{/* Hub shine */}
|
||||||
<ellipse
|
<ellipse cx={center - 10} cy={center - 12} rx={15} ry={10} fill="rgba(255,255,255,0.2)" />
|
||||||
cx={center - 10}
|
|
||||||
cy={center - 12}
|
|
||||||
rx={15}
|
|
||||||
ry={10}
|
|
||||||
fill="rgba(255,255,255,0.2)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Center button */}
|
{/* Center button */}
|
||||||
<circle
|
<circle
|
||||||
@@ -422,7 +429,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
{/* Spinning overlay glow */}
|
{/* Spinning overlay glow */}
|
||||||
{isSpinning && (
|
{isSpinning && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 rounded-full pointer-events-none"
|
className="pointer-events-none absolute inset-0 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
|
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
|
||||||
animation: 'pulse 0.5s ease-in-out infinite',
|
animation: 'pulse 0.5s ease-in-out infinite',
|
||||||
@@ -433,11 +440,11 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
|
|
||||||
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
|
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
|
||||||
{isSpinning && (
|
{isSpinning && (
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
{SPARKLE_POSITIONS.map((pos, i) => (
|
{SPARKLE_POSITIONS.map((pos, i) => (
|
||||||
<div
|
<div
|
||||||
key={`sparkle-${i}`}
|
key={`sparkle-${i}`}
|
||||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
|
className="absolute h-2 w-2 animate-ping rounded-full bg-yellow-300"
|
||||||
style={{
|
style={{
|
||||||
top: pos.top,
|
top: pos.top,
|
||||||
left: pos.left,
|
left: pos.left,
|
||||||
@@ -450,7 +457,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
export default FortuneWheel
|
export default FortuneWheel;
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { ThemeColors } from '../types/theme'
|
import { ThemeColors } from '../types/theme';
|
||||||
|
|
||||||
export interface ColorPreset {
|
export interface ColorPreset {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
nameRu: string
|
nameRu: string;
|
||||||
description: string
|
description: string;
|
||||||
descriptionRu: string
|
descriptionRu: string;
|
||||||
colors: ThemeColors
|
colors: ThemeColors;
|
||||||
preview: {
|
preview: {
|
||||||
background: string
|
background: string;
|
||||||
accent: string
|
accent: string;
|
||||||
text: string
|
text: string;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COLOR_PRESETS: ColorPreset[] = [
|
export const COLOR_PRESETS: ColorPreset[] = [
|
||||||
@@ -275,8 +275,8 @@ export const COLOR_PRESETS: ColorPreset[] = [
|
|||||||
text: '#f1f5f9',
|
text: '#f1f5f9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
export function getPresetById(id: string): ColorPreset | undefined {
|
export function getPresetById(id: string): ColorPreset | undefined {
|
||||||
return COLOR_PRESETS.find((preset) => preset.id === id)
|
return COLOR_PRESETS.find((preset) => preset.id === id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { brandingApi } from '../api/branding'
|
import { brandingApi } from '../api/branding';
|
||||||
|
|
||||||
const YM_SCRIPT_ID = 'ym-counter-script'
|
const YM_SCRIPT_ID = 'ym-counter-script';
|
||||||
const GTAG_LOADER_ID = 'gtag-loader-script'
|
const GTAG_LOADER_ID = 'gtag-loader-script';
|
||||||
const GTAG_INIT_ID = 'gtag-init-script'
|
const GTAG_INIT_ID = 'gtag-init-script';
|
||||||
|
|
||||||
function removeElement(id: string) {
|
function removeElement(id: string) {
|
||||||
document.getElementById(id)?.remove()
|
document.getElementById(id)?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectYandexMetrika(counterId: string) {
|
function injectYandexMetrika(counterId: string) {
|
||||||
if (document.getElementById(YM_SCRIPT_ID)) return
|
if (document.getElementById(YM_SCRIPT_ID)) return;
|
||||||
|
|
||||||
const script = document.createElement('script')
|
const script = document.createElement('script');
|
||||||
script.id = YM_SCRIPT_ID
|
script.id = YM_SCRIPT_ID;
|
||||||
script.type = 'text/javascript'
|
script.type = 'text/javascript';
|
||||||
script.textContent = `
|
script.textContent = `
|
||||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
m[i].l=1*new Date();
|
m[i].l=1*new Date();
|
||||||
@@ -26,30 +26,30 @@ function injectYandexMetrika(counterId: string) {
|
|||||||
trackLinks:true,
|
trackLinks:true,
|
||||||
accurateTrackBounce:true
|
accurateTrackBounce:true
|
||||||
});
|
});
|
||||||
`
|
`;
|
||||||
document.head.appendChild(script)
|
document.head.appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectGoogleAds(conversionId: string) {
|
function injectGoogleAds(conversionId: string) {
|
||||||
if (document.getElementById(GTAG_LOADER_ID)) return
|
if (document.getElementById(GTAG_LOADER_ID)) return;
|
||||||
|
|
||||||
// External gtag.js loader
|
// External gtag.js loader
|
||||||
const loader = document.createElement('script')
|
const loader = document.createElement('script');
|
||||||
loader.id = GTAG_LOADER_ID
|
loader.id = GTAG_LOADER_ID;
|
||||||
loader.async = true
|
loader.async = true;
|
||||||
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`
|
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`;
|
||||||
document.head.appendChild(loader)
|
document.head.appendChild(loader);
|
||||||
|
|
||||||
// Init script
|
// Init script
|
||||||
const init = document.createElement('script')
|
const init = document.createElement('script');
|
||||||
init.id = GTAG_INIT_ID
|
init.id = GTAG_INIT_ID;
|
||||||
init.textContent = `
|
init.textContent = `
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
function gtag(){dataLayer.push(arguments);}
|
function gtag(){dataLayer.push(arguments);}
|
||||||
gtag('js', new Date());
|
gtag('js', new Date());
|
||||||
gtag('config', '${conversionId}');
|
gtag('config', '${conversionId}');
|
||||||
`
|
`;
|
||||||
document.head.appendChild(init)
|
document.head.appendChild(init);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,24 +61,24 @@ export function useAnalyticsCounters() {
|
|||||||
queryKey: ['analytics-counters'],
|
queryKey: ['analytics-counters'],
|
||||||
queryFn: brandingApi.getAnalyticsCounters,
|
queryFn: brandingApi.getAnalyticsCounters,
|
||||||
staleTime: 5 * 60 * 1000, // 5 min
|
staleTime: 5 * 60 * 1000, // 5 min
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return;
|
||||||
|
|
||||||
// Yandex Metrika
|
// Yandex Metrika
|
||||||
if (data.yandex_metrika_id) {
|
if (data.yandex_metrika_id) {
|
||||||
injectYandexMetrika(data.yandex_metrika_id)
|
injectYandexMetrika(data.yandex_metrika_id);
|
||||||
} else {
|
} else {
|
||||||
removeElement(YM_SCRIPT_ID)
|
removeElement(YM_SCRIPT_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google Ads
|
// Google Ads
|
||||||
if (data.google_ads_id) {
|
if (data.google_ads_id) {
|
||||||
injectGoogleAds(data.google_ads_id)
|
injectGoogleAds(data.google_ads_id);
|
||||||
} else {
|
} else {
|
||||||
removeElement(GTAG_LOADER_ID)
|
removeElement(GTAG_LOADER_ID);
|
||||||
removeElement(GTAG_INIT_ID)
|
removeElement(GTAG_INIT_ID);
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { currencyApi, type ExchangeRates } from '../api/currency'
|
import { currencyApi, type ExchangeRates } from '../api/currency';
|
||||||
|
|
||||||
// Map language to currency
|
// Map language to currency
|
||||||
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
||||||
@@ -8,17 +8,17 @@ const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
|||||||
en: 'USD',
|
en: 'USD',
|
||||||
zh: 'CNY',
|
zh: 'CNY',
|
||||||
fa: 'IRR',
|
fa: 'IRR',
|
||||||
}
|
};
|
||||||
|
|
||||||
// Default rates for fallback
|
// Default rates for fallback
|
||||||
const DEFAULT_RATES: ExchangeRates = {
|
const DEFAULT_RATES: ExchangeRates = {
|
||||||
USD: 100,
|
USD: 100,
|
||||||
CNY: 14,
|
CNY: 14,
|
||||||
IRR: 0.0024,
|
IRR: 0.0024,
|
||||||
}
|
};
|
||||||
|
|
||||||
export function useCurrency() {
|
export function useCurrency() {
|
||||||
const { i18n, t } = useTranslation()
|
const { i18n, t } = useTranslation();
|
||||||
|
|
||||||
// Fetch exchange rates
|
// Fetch exchange rates
|
||||||
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
|
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
|
||||||
@@ -27,72 +27,68 @@ export function useCurrency() {
|
|||||||
staleTime: 60 * 60 * 1000, // 1 hour
|
staleTime: 60 * 60 * 1000, // 1 hour
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Get current language and currency
|
// Get current language and currency
|
||||||
const currentLanguage = i18n.language
|
const currentLanguage = i18n.language;
|
||||||
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD'
|
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
|
||||||
|
|
||||||
// Check if current language is Russian (no conversion needed)
|
// Check if current language is Russian (no conversion needed)
|
||||||
const isRussian = currentLanguage === 'ru'
|
const isRussian = currentLanguage === 'ru';
|
||||||
|
|
||||||
// Get currency symbol from translations
|
// Get currency symbol from translations
|
||||||
const currencySymbol = t('common.currency')
|
const currencySymbol = t('common.currency');
|
||||||
|
|
||||||
// Format amount with currency conversion
|
// Format amount with currency conversion
|
||||||
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
|
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
|
||||||
if (isRussian) {
|
if (isRussian) {
|
||||||
return rubAmount.toFixed(decimals)
|
return rubAmount.toFixed(decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to target currency
|
// Convert to target currency
|
||||||
const convertedAmount = currencyApi.convertFromRub(
|
const convertedAmount = currencyApi.convertFromRub(
|
||||||
rubAmount,
|
rubAmount,
|
||||||
targetCurrency as keyof ExchangeRates,
|
targetCurrency as keyof ExchangeRates,
|
||||||
exchangeRates
|
exchangeRates,
|
||||||
)
|
);
|
||||||
|
|
||||||
// For IRR (Iranian Toman), use no decimals as amounts are large
|
// For IRR (Iranian Toman), use no decimals as amounts are large
|
||||||
if (targetCurrency === 'IRR') {
|
if (targetCurrency === 'IRR') {
|
||||||
return Math.round(convertedAmount).toLocaleString('fa-IR')
|
return Math.round(convertedAmount).toLocaleString('fa-IR');
|
||||||
}
|
}
|
||||||
|
|
||||||
return convertedAmount.toFixed(decimals)
|
return convertedAmount.toFixed(decimals);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Format amount with currency symbol
|
// Format amount with currency symbol
|
||||||
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
|
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
|
||||||
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`
|
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Format amount with + sign (for earnings/bonuses)
|
// Format amount with + sign (for earnings/bonuses)
|
||||||
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
|
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
|
||||||
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`
|
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get raw converted amount (for calculations)
|
// Get raw converted amount (for calculations)
|
||||||
const convertAmount = (rubAmount: number): number => {
|
const convertAmount = (rubAmount: number): number => {
|
||||||
if (isRussian) {
|
if (isRussian) {
|
||||||
return rubAmount
|
return rubAmount;
|
||||||
}
|
}
|
||||||
return currencyApi.convertFromRub(
|
return currencyApi.convertFromRub(
|
||||||
rubAmount,
|
rubAmount,
|
||||||
targetCurrency as keyof ExchangeRates,
|
targetCurrency as keyof ExchangeRates,
|
||||||
exchangeRates
|
exchangeRates,
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Convert from user's currency back to rubles
|
// Convert from user's currency back to rubles
|
||||||
const convertToRub = (amount: number): number => {
|
const convertToRub = (amount: number): number => {
|
||||||
if (isRussian) {
|
if (isRussian) {
|
||||||
return amount
|
return amount;
|
||||||
}
|
|
||||||
return currencyApi.convertToRub(
|
|
||||||
amount,
|
|
||||||
targetCurrency as keyof ExchangeRates,
|
|
||||||
exchangeRates
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exchangeRates,
|
exchangeRates,
|
||||||
@@ -104,5 +100,5 @@ export function useCurrency() {
|
|||||||
formatPositive,
|
formatPositive,
|
||||||
convertAmount,
|
convertAmount,
|
||||||
convertToRub,
|
convertToRub,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,52 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
const STORAGE_KEY = 'admin_favorite_settings'
|
const STORAGE_KEY = 'admin_favorite_settings';
|
||||||
|
|
||||||
export function useFavoriteSettings() {
|
export function useFavoriteSettings() {
|
||||||
const [favorites, setFavorites] = useState<string[]>(() => {
|
const [favorites, setFavorites] = useState<string[]>(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY)
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
return stored ? JSON.parse(stored) : []
|
return stored ? JSON.parse(stored) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// Sync to localStorage
|
// Sync to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage errors
|
// Ignore storage errors
|
||||||
}
|
}
|
||||||
}, [favorites])
|
}, [favorites]);
|
||||||
|
|
||||||
const toggleFavorite = useCallback((settingKey: string) => {
|
const toggleFavorite = useCallback((settingKey: string) => {
|
||||||
setFavorites(prev => {
|
setFavorites((prev) => {
|
||||||
if (prev.includes(settingKey)) {
|
if (prev.includes(settingKey)) {
|
||||||
return prev.filter(key => key !== settingKey)
|
return prev.filter((key) => key !== settingKey);
|
||||||
} else {
|
} else {
|
||||||
return [...prev, settingKey]
|
return [...prev, settingKey];
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const isFavorite = useCallback((settingKey: string) => {
|
const isFavorite = useCallback(
|
||||||
return favorites.includes(settingKey)
|
(settingKey: string) => {
|
||||||
}, [favorites])
|
return favorites.includes(settingKey);
|
||||||
|
},
|
||||||
|
[favorites],
|
||||||
|
);
|
||||||
|
|
||||||
const clearFavorites = useCallback(() => {
|
const clearFavorites = useCallback(() => {
|
||||||
setFavorites([])
|
setFavorites([]);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
favorites,
|
favorites,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
clearFavorites,
|
clearFavorites,
|
||||||
count: favorites.length
|
count: favorites.length,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
interface UsePullToRefreshOptions {
|
interface UsePullToRefreshOptions {
|
||||||
onRefresh?: () => void | Promise<void>
|
onRefresh?: () => void | Promise<void>;
|
||||||
threshold?: number // How far to pull before triggering refresh (px)
|
threshold?: number; // How far to pull before triggering refresh (px)
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePullToRefresh({
|
export function usePullToRefresh({
|
||||||
@@ -11,150 +11,152 @@ export function usePullToRefresh({
|
|||||||
threshold = 80,
|
threshold = 80,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: UsePullToRefreshOptions = {}) {
|
}: UsePullToRefreshOptions = {}) {
|
||||||
const [isPulling, setIsPulling] = useState(false)
|
const [isPulling, setIsPulling] = useState(false);
|
||||||
const [pullDistance, setPullDistance] = useState(0)
|
const [pullDistance, setPullDistance] = useState(0);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const startY = useRef(0)
|
const startY = useRef(0);
|
||||||
const currentY = useRef(0)
|
const currentY = useRef(0);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
if (onRefresh) {
|
if (onRefresh) {
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await onRefresh()
|
await onRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default: reload the page
|
// Default: reload the page
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}, [onRefresh])
|
}, [onRefresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled) return
|
if (disabled) return;
|
||||||
|
|
||||||
// Check if a modal/overlay is currently open
|
// Check if a modal/overlay is currently open
|
||||||
const isModalOpen = (): boolean => {
|
const isModalOpen = (): boolean => {
|
||||||
// Check if body scroll is locked (common pattern for modals)
|
// Check if body scroll is locked (common pattern for modals)
|
||||||
if (document.body.style.overflow === 'hidden') return true
|
if (document.body.style.overflow === 'hidden') return true;
|
||||||
// Check for high z-index fixed elements (modals typically use z-index > 50)
|
// Check for high z-index fixed elements (modals typically use z-index > 50)
|
||||||
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]')
|
const fixedElements = document.querySelectorAll(
|
||||||
|
'[style*="position: fixed"], [style*="position:fixed"]',
|
||||||
|
);
|
||||||
for (const el of fixedElements) {
|
for (const el of fixedElements) {
|
||||||
const style = window.getComputedStyle(el)
|
const style = window.getComputedStyle(el);
|
||||||
const zIndex = parseInt(style.zIndex, 10)
|
const zIndex = parseInt(style.zIndex, 10);
|
||||||
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
|
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Check if element or any parent is scrollable and not at top
|
// Check if element or any parent is scrollable and not at top
|
||||||
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
|
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
|
||||||
while (element && element !== document.body) {
|
while (element && element !== document.body) {
|
||||||
const style = window.getComputedStyle(element)
|
const style = window.getComputedStyle(element);
|
||||||
const overflowY = style.overflowY
|
const overflowY = style.overflowY;
|
||||||
const isScrollable = overflowY === 'auto' || overflowY === 'scroll'
|
const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
|
||||||
|
|
||||||
if (isScrollable && element.scrollHeight > element.clientHeight) {
|
if (isScrollable && element.scrollHeight > element.clientHeight) {
|
||||||
// Element is scrollable - check if it's at the top
|
// Element is scrollable - check if it's at the top
|
||||||
if (element.scrollTop > 0) {
|
if (element.scrollTop > 0) {
|
||||||
return true // Not at top, don't trigger pull-to-refresh
|
return true; // Not at top, don't trigger pull-to-refresh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
element = element.parentElement
|
element = element.parentElement;
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
// Don't trigger if a modal is open
|
// Don't trigger if a modal is open
|
||||||
if (isModalOpen()) return
|
if (isModalOpen()) return;
|
||||||
|
|
||||||
// Only trigger if at top of page
|
// Only trigger if at top of page
|
||||||
if (window.scrollY > 5) return
|
if (window.scrollY > 5) return;
|
||||||
|
|
||||||
// Don't trigger if touch started inside a scrollable container that's not at top
|
// Don't trigger if touch started inside a scrollable container that's not at top
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (isInsideScrollableContainer(target)) return
|
if (isInsideScrollableContainer(target)) return;
|
||||||
|
|
||||||
startY.current = e.touches[0].clientY
|
startY.current = e.touches[0].clientY;
|
||||||
currentY.current = e.touches[0].clientY
|
currentY.current = e.touches[0].clientY;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
if (startY.current === 0) return
|
if (startY.current === 0) return;
|
||||||
|
|
||||||
// Cancel if modal opened during gesture
|
// Cancel if modal opened during gesture
|
||||||
if (isModalOpen()) {
|
if (isModalOpen()) {
|
||||||
startY.current = 0
|
startY.current = 0;
|
||||||
setPullDistance(0)
|
setPullDistance(0);
|
||||||
setIsPulling(false)
|
setIsPulling(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.scrollY > 5) {
|
if (window.scrollY > 5) {
|
||||||
// User scrolled down, reset
|
// User scrolled down, reset
|
||||||
startY.current = 0
|
startY.current = 0;
|
||||||
setPullDistance(0)
|
setPullDistance(0);
|
||||||
setIsPulling(false)
|
setIsPulling(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check during move - user might start at top then scroll inside container
|
// Also check during move - user might start at top then scroll inside container
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (isInsideScrollableContainer(target)) {
|
if (isInsideScrollableContainer(target)) {
|
||||||
startY.current = 0
|
startY.current = 0;
|
||||||
setPullDistance(0)
|
setPullDistance(0);
|
||||||
setIsPulling(false)
|
setIsPulling(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentY.current = e.touches[0].clientY
|
currentY.current = e.touches[0].clientY;
|
||||||
const diff = currentY.current - startY.current
|
const diff = currentY.current - startY.current;
|
||||||
|
|
||||||
// Only track downward pulls
|
// Only track downward pulls
|
||||||
if (diff > 0) {
|
if (diff > 0) {
|
||||||
// Apply resistance - pull distance is less than actual finger movement
|
// Apply resistance - pull distance is less than actual finger movement
|
||||||
const resistance = 0.4
|
const resistance = 0.4;
|
||||||
const distance = Math.min(diff * resistance, threshold * 1.5)
|
const distance = Math.min(diff * resistance, threshold * 1.5);
|
||||||
setPullDistance(distance)
|
setPullDistance(distance);
|
||||||
setIsPulling(true)
|
setIsPulling(true);
|
||||||
|
|
||||||
// Prevent default scroll if we're pulling
|
// Prevent default scroll if we're pulling
|
||||||
if (distance > 10) {
|
if (distance > 10) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
if (pullDistance >= threshold && !isRefreshing) {
|
if (pullDistance >= threshold && !isRefreshing) {
|
||||||
handleRefresh()
|
handleRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
startY.current = 0
|
startY.current = 0;
|
||||||
setPullDistance(0)
|
setPullDistance(0);
|
||||||
setIsPulling(false)
|
setIsPulling(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('touchstart', handleTouchStart)
|
document.removeEventListener('touchstart', handleTouchStart);
|
||||||
document.removeEventListener('touchmove', handleTouchMove)
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
document.removeEventListener('touchend', handleTouchEnd)
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
}
|
};
|
||||||
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
|
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPulling,
|
isPulling,
|
||||||
pullDistance,
|
pullDistance,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
progress: Math.min(pullDistance / threshold, 1),
|
progress: Math.min(pullDistance / threshold, 1),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'
|
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||||
|
|
||||||
// Get cached fullscreen setting
|
// Get cached fullscreen setting
|
||||||
export const getCachedFullscreenEnabled = (): boolean => {
|
export const getCachedFullscreenEnabled = (): boolean => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
|
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Set cached fullscreen setting
|
// Set cached fullscreen setting
|
||||||
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
|
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage not available
|
// localStorage not available
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for Telegram WebApp API integration
|
* Hook for Telegram WebApp API integration
|
||||||
@@ -26,102 +26,112 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
|||||||
*/
|
*/
|
||||||
export function useTelegramWebApp() {
|
export function useTelegramWebApp() {
|
||||||
// Initialize synchronously to avoid flash/flicker on first render
|
// Initialize synchronously to avoid flash/flicker on first render
|
||||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
|
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false)
|
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
|
||||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp)
|
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
|
||||||
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
const [safeAreaInset, setSafeAreaInset] = useState(
|
||||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
|
);
|
||||||
|
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
|
||||||
|
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!webApp) {
|
if (!webApp) {
|
||||||
setIsTelegramWebApp(false)
|
setIsTelegramWebApp(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsTelegramWebApp(true)
|
setIsTelegramWebApp(true);
|
||||||
setIsFullscreen(webApp.isFullscreen || false)
|
setIsFullscreen(webApp.isFullscreen || false);
|
||||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
setContentSafeAreaInset(
|
||||||
|
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
// Expand WebApp to full height
|
// Expand WebApp to full height
|
||||||
webApp.expand()
|
webApp.expand();
|
||||||
webApp.ready()
|
webApp.ready();
|
||||||
|
|
||||||
// Listen for fullscreen changes
|
// Listen for fullscreen changes
|
||||||
const handleFullscreenChanged = () => {
|
const handleFullscreenChanged = () => {
|
||||||
setIsFullscreen(webApp.isFullscreen || false)
|
setIsFullscreen(webApp.isFullscreen || false);
|
||||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
setContentSafeAreaInset(
|
||||||
}
|
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Listen for safe area changes
|
// Listen for safe area changes
|
||||||
const handleSafeAreaChanged = () => {
|
const handleSafeAreaChanged = () => {
|
||||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
setContentSafeAreaInset(
|
||||||
}
|
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
|
webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
|
||||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
|
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
|
||||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
|
webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
|
||||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
|
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
|
||||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
||||||
}
|
};
|
||||||
}, [webApp])
|
}, [webApp]);
|
||||||
|
|
||||||
const requestFullscreen = useCallback(() => {
|
const requestFullscreen = useCallback(() => {
|
||||||
if (webApp?.requestFullscreen) {
|
if (webApp?.requestFullscreen) {
|
||||||
try {
|
try {
|
||||||
webApp.requestFullscreen()
|
webApp.requestFullscreen();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Fullscreen not supported:', e)
|
console.warn('Fullscreen not supported:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [webApp])
|
}, [webApp]);
|
||||||
|
|
||||||
const exitFullscreen = useCallback(() => {
|
const exitFullscreen = useCallback(() => {
|
||||||
if (webApp?.exitFullscreen) {
|
if (webApp?.exitFullscreen) {
|
||||||
try {
|
try {
|
||||||
webApp.exitFullscreen()
|
webApp.exitFullscreen();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Exit fullscreen failed:', e)
|
console.warn('Exit fullscreen failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [webApp])
|
}, [webApp]);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
exitFullscreen()
|
exitFullscreen();
|
||||||
} else {
|
} else {
|
||||||
requestFullscreen()
|
requestFullscreen();
|
||||||
}
|
}
|
||||||
}, [isFullscreen, requestFullscreen, exitFullscreen])
|
}, [isFullscreen, requestFullscreen, exitFullscreen]);
|
||||||
|
|
||||||
const disableVerticalSwipes = useCallback(() => {
|
const disableVerticalSwipes = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||||
webApp.disableVerticalSwipes()
|
webApp.disableVerticalSwipes();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not supported in this version
|
// Not supported in this version
|
||||||
}
|
}
|
||||||
}, [webApp])
|
}, [webApp]);
|
||||||
|
|
||||||
const enableVerticalSwipes = useCallback(() => {
|
const enableVerticalSwipes = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||||
webApp.enableVerticalSwipes()
|
webApp.enableVerticalSwipes();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not supported in this version
|
// Not supported in this version
|
||||||
}
|
}
|
||||||
}, [webApp])
|
}, [webApp]);
|
||||||
|
|
||||||
// Check if fullscreen is supported (Bot API 8.0+)
|
// Check if fullscreen is supported (Bot API 8.0+)
|
||||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
|
const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTelegramWebApp,
|
isTelegramWebApp,
|
||||||
@@ -135,7 +145,7 @@ export function useTelegramWebApp() {
|
|||||||
disableVerticalSwipes,
|
disableVerticalSwipes,
|
||||||
enableVerticalSwipes,
|
enableVerticalSwipes,
|
||||||
webApp,
|
webApp,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,27 +153,27 @@ export function useTelegramWebApp() {
|
|||||||
* Call this in main.tsx or App.tsx
|
* Call this in main.tsx or App.tsx
|
||||||
*/
|
*/
|
||||||
export function initTelegramWebApp() {
|
export function initTelegramWebApp() {
|
||||||
const webApp = window.Telegram?.WebApp
|
const webApp = window.Telegram?.WebApp;
|
||||||
if (webApp) {
|
if (webApp) {
|
||||||
webApp.ready()
|
webApp.ready();
|
||||||
webApp.expand()
|
webApp.expand();
|
||||||
|
|
||||||
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||||
try {
|
try {
|
||||||
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||||
webApp.disableVerticalSwipes()
|
webApp.disableVerticalSwipes();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Swipe control not supported in this version
|
// Swipe control not supported in this version
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||||
const fullscreenEnabled = getCachedFullscreenEnabled()
|
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||||
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
|
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||||
try {
|
try {
|
||||||
webApp.requestFullscreen()
|
webApp.requestFullscreen();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Auto-fullscreen failed:', e)
|
console.warn('Auto-fullscreen failed:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +1,224 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
|
||||||
import { themeColorsApi } from '../api/themeColors'
|
import { themeColorsApi } from '../api/themeColors';
|
||||||
|
|
||||||
type Theme = 'dark' | 'light'
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
const THEME_KEY = 'cabinet-theme'
|
const THEME_KEY = 'cabinet-theme';
|
||||||
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes'
|
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
|
||||||
|
|
||||||
// Fetch enabled themes from API
|
// Fetch enabled themes from API
|
||||||
async function fetchEnabledThemes(): Promise<EnabledThemes> {
|
async function fetchEnabledThemes(): Promise<EnabledThemes> {
|
||||||
try {
|
try {
|
||||||
const data = await themeColorsApi.getEnabledThemes()
|
const data = await themeColorsApi.getEnabledThemes();
|
||||||
// Cache in localStorage for faster subsequent loads
|
// Cache in localStorage for faster subsequent loads
|
||||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data))
|
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
|
||||||
return data
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors, use cached or default
|
// Ignore errors, use cached or default
|
||||||
}
|
}
|
||||||
// Try to get from cache
|
// Try to get from cache
|
||||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(cached)
|
return JSON.parse(cached);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_ENABLED_THEMES
|
return DEFAULT_ENABLED_THEMES;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cached enabled themes synchronously
|
// Get cached enabled themes synchronously
|
||||||
function getCachedEnabledThemes(): EnabledThemes {
|
function getCachedEnabledThemes(): EnabledThemes {
|
||||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(cached)
|
return JSON.parse(cached);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_ENABLED_THEMES
|
return DEFAULT_ENABLED_THEMES;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom event for same-tab updates
|
// Custom event for same-tab updates
|
||||||
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged'
|
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
|
||||||
|
|
||||||
// Update cache (called from admin settings)
|
// Update cache (called from admin settings)
|
||||||
export function updateEnabledThemesCache(themes: EnabledThemes) {
|
export function updateEnabledThemesCache(themes: EnabledThemes) {
|
||||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes))
|
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes));
|
||||||
// Dispatch custom event for same-tab updates
|
// Dispatch custom event for same-tab updates
|
||||||
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }))
|
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes)
|
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const [theme, setThemeState] = useState<Theme>(() => {
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
const enabled = getCachedEnabledThemes()
|
const enabled = getCachedEnabledThemes();
|
||||||
|
|
||||||
// Check localStorage first
|
// Check localStorage first
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null
|
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
|
||||||
if (stored === 'light' && enabled.light) {
|
if (stored === 'light' && enabled.light) {
|
||||||
return 'light'
|
return 'light';
|
||||||
}
|
}
|
||||||
if (stored === 'dark' && enabled.dark) {
|
if (stored === 'dark' && enabled.dark) {
|
||||||
return 'dark'
|
return 'dark';
|
||||||
}
|
}
|
||||||
// If stored theme is disabled, use the enabled one
|
// If stored theme is disabled, use the enabled one
|
||||||
if (stored && !enabled[stored]) {
|
if (stored && !enabled[stored]) {
|
||||||
return enabled.dark ? 'dark' : 'light'
|
return enabled.dark ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
// Check system preference
|
// Check system preference
|
||||||
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
||||||
return 'light'
|
return 'light';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default to dark if enabled, otherwise light
|
// Default to dark if enabled, otherwise light
|
||||||
return enabled.dark ? 'dark' : 'light'
|
return enabled.dark ? 'dark' : 'light';
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch enabled themes on mount
|
// Fetch enabled themes on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEnabledThemes().then((data) => {
|
fetchEnabledThemes().then((data) => {
|
||||||
setEnabledThemes(data)
|
setEnabledThemes(data);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
// If current theme is disabled, switch to enabled one
|
// If current theme is disabled, switch to enabled one
|
||||||
if (!data[theme]) {
|
if (!data[theme]) {
|
||||||
const newTheme = data.dark ? 'dark' : 'light'
|
const newTheme = data.dark ? 'dark' : 'light';
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
|
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorageChange = (e: StorageEvent) => {
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
|
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.newValue) as EnabledThemes
|
const data = JSON.parse(e.newValue) as EnabledThemes;
|
||||||
setEnabledThemes(data)
|
setEnabledThemes(data);
|
||||||
// If current theme is now disabled, switch to enabled one
|
// If current theme is now disabled, switch to enabled one
|
||||||
if (!data[theme]) {
|
if (!data[theme]) {
|
||||||
const newTheme = data.dark ? 'dark' : 'light'
|
const newTheme = data.dark ? 'dark' : 'light';
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorageChange)
|
window.addEventListener('storage', handleStorageChange);
|
||||||
return () => window.removeEventListener('storage', handleStorageChange)
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
}, [theme])
|
}, [theme]);
|
||||||
|
|
||||||
// Listen for same-tab enabled themes changes (from admin settings)
|
// Listen for same-tab enabled themes changes (from admin settings)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
|
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
|
||||||
const data = e.detail
|
const data = e.detail;
|
||||||
setEnabledThemes(data)
|
setEnabledThemes(data);
|
||||||
// If current theme is now disabled, switch to enabled one
|
// If current theme is now disabled, switch to enabled one
|
||||||
if (!data[theme]) {
|
if (!data[theme]) {
|
||||||
const newTheme = data.dark ? 'dark' : 'light'
|
const newTheme = data.dark ? 'dark' : 'light';
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
|
window.addEventListener(
|
||||||
return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
|
ENABLED_THEMES_CHANGED_EVENT,
|
||||||
}, [theme])
|
handleEnabledThemesChange as EventListener,
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
ENABLED_THEMES_CHANGED_EVENT,
|
||||||
|
handleEnabledThemesChange as EventListener,
|
||||||
|
);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
// Apply theme to document - also check if theme is disabled and switch
|
// Apply theme to document - also check if theme is disabled and switch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement;
|
||||||
|
|
||||||
// If current theme is disabled, switch to the enabled one
|
// If current theme is disabled, switch to the enabled one
|
||||||
if (!enabledThemes[theme]) {
|
if (!enabledThemes[theme]) {
|
||||||
const newTheme = enabledThemes.dark ? 'dark' : 'light'
|
const newTheme = enabledThemes.dark ? 'dark' : 'light';
|
||||||
if (newTheme !== theme) {
|
if (newTheme !== theme) {
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
return // Will re-run with correct theme
|
return; // Will re-run with correct theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (theme === 'light') {
|
if (theme === 'light') {
|
||||||
root.classList.remove('dark')
|
root.classList.remove('dark');
|
||||||
root.classList.add('light')
|
root.classList.add('light');
|
||||||
} else {
|
} else {
|
||||||
root.classList.remove('light')
|
root.classList.remove('light');
|
||||||
root.classList.add('dark')
|
root.classList.add('dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(THEME_KEY, theme)
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
}, [theme, enabledThemes])
|
}, [theme, enabledThemes]);
|
||||||
|
|
||||||
// Listen for system theme changes
|
// Listen for system theme changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
|
||||||
|
|
||||||
const handleChange = (e: MediaQueryListEvent) => {
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
const stored = localStorage.getItem(THEME_KEY)
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
// Only auto-switch if user hasn't set a preference and theme is enabled
|
// Only auto-switch if user hasn't set a preference and theme is enabled
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
const newTheme = e.matches ? 'light' : 'dark'
|
const newTheme = e.matches ? 'light' : 'dark';
|
||||||
if (enabledThemes[newTheme]) {
|
if (enabledThemes[newTheme]) {
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange)
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
}, [enabledThemes])
|
}, [enabledThemes]);
|
||||||
|
|
||||||
const setTheme = useCallback((newTheme: Theme) => {
|
const setTheme = useCallback(
|
||||||
|
(newTheme: Theme) => {
|
||||||
// Only allow setting if theme is enabled
|
// Only allow setting if theme is enabled
|
||||||
if (enabledThemes[newTheme]) {
|
if (enabledThemes[newTheme]) {
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
}
|
}
|
||||||
}, [enabledThemes])
|
},
|
||||||
|
[enabledThemes],
|
||||||
|
);
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
const toggleTheme = useCallback(() => {
|
||||||
setThemeState((prev) => {
|
setThemeState((prev) => {
|
||||||
const newTheme = prev === 'dark' ? 'light' : 'dark'
|
const newTheme = prev === 'dark' ? 'light' : 'dark';
|
||||||
// Only toggle if the new theme is enabled
|
// Only toggle if the new theme is enabled
|
||||||
if (enabledThemes[newTheme]) {
|
if (enabledThemes[newTheme]) {
|
||||||
return newTheme
|
return newTheme;
|
||||||
}
|
}
|
||||||
return prev
|
return prev;
|
||||||
})
|
});
|
||||||
}, [enabledThemes])
|
}, [enabledThemes]);
|
||||||
|
|
||||||
const isDark = theme === 'dark'
|
const isDark = theme === 'dark';
|
||||||
const isLight = theme === 'light'
|
const isLight = theme === 'light';
|
||||||
|
|
||||||
// Check if theme switching is available (both themes enabled and loaded)
|
// Check if theme switching is available (both themes enabled and loaded)
|
||||||
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light
|
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light;
|
||||||
|
|
||||||
// Refresh enabled themes from API
|
// Refresh enabled themes from API
|
||||||
const refreshEnabledThemes = useCallback(() => {
|
const refreshEnabledThemes = useCallback(() => {
|
||||||
fetchEnabledThemes().then((data) => {
|
fetchEnabledThemes().then((data) => {
|
||||||
setEnabledThemes(data)
|
setEnabledThemes(data);
|
||||||
if (!data[theme]) {
|
if (!data[theme]) {
|
||||||
const newTheme = data.dark ? 'dark' : 'light'
|
const newTheme = data.dark ? 'dark' : 'light';
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}, [theme])
|
}, [theme]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
@@ -220,5 +230,5 @@ export function useTheme() {
|
|||||||
canToggle,
|
canToggle,
|
||||||
isLoading,
|
isLoading,
|
||||||
refreshEnabledThemes,
|
refreshEnabledThemes,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { themeColorsApi } from '../api/themeColors'
|
import { themeColorsApi } from '../api/themeColors';
|
||||||
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme'
|
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
|
||||||
|
|
||||||
// Convert hex to RGB values
|
// Convert hex to RGB values
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
// Handle shorthand hex
|
// Handle shorthand hex
|
||||||
if (hex.length === 4) {
|
if (hex.length === 4) {
|
||||||
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
|
||||||
}
|
}
|
||||||
const r = parseInt(hex.slice(1, 3), 16)
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16)
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
const b = parseInt(hex.slice(5, 7), 16)
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
return { r, g, b }
|
return { r, g, b };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert hex to HSL
|
// Convert hex to HSL
|
||||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||||
const { r, g, b } = hexToRgb(hex)
|
const { r, g, b } = hexToRgb(hex);
|
||||||
const rNorm = r / 255
|
const rNorm = r / 255;
|
||||||
const gNorm = g / 255
|
const gNorm = g / 255;
|
||||||
const bNorm = b / 255
|
const bNorm = b / 255;
|
||||||
|
|
||||||
const max = Math.max(rNorm, gNorm, bNorm)
|
const max = Math.max(rNorm, gNorm, bNorm);
|
||||||
const min = Math.min(rNorm, gNorm, bNorm)
|
const min = Math.min(rNorm, gNorm, bNorm);
|
||||||
let h = 0
|
let h = 0;
|
||||||
let s = 0
|
let s = 0;
|
||||||
const l = (max + min) / 2
|
const l = (max + min) / 2;
|
||||||
|
|
||||||
if (max !== min) {
|
if (max !== min) {
|
||||||
const d = max - min
|
const d = max - min;
|
||||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
|
||||||
switch (max) {
|
switch (max) {
|
||||||
case rNorm:
|
case rNorm:
|
||||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
|
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
|
||||||
break
|
break;
|
||||||
case gNorm:
|
case gNorm:
|
||||||
h = ((bNorm - rNorm) / d + 2) / 6
|
h = ((bNorm - rNorm) / d + 2) / 6;
|
||||||
break
|
break;
|
||||||
case bNorm:
|
case bNorm:
|
||||||
h = ((rNorm - gNorm) / d + 4) / 6
|
h = ((rNorm - gNorm) / d + 4) / 6;
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert HSL to RGB values
|
// Convert HSL to RGB values
|
||||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||||
s /= 100
|
s /= 100;
|
||||||
l /= 100
|
l /= 100;
|
||||||
|
|
||||||
const a = s * Math.min(l, 1 - l)
|
const a = s * Math.min(l, 1 - l);
|
||||||
const f = (n: number) => {
|
const f = (n: number) => {
|
||||||
const k = (n + h / 30) % 12
|
const k = (n + h / 30) % 12;
|
||||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
return Math.round(255 * color)
|
return Math.round(255 * color);
|
||||||
}
|
};
|
||||||
|
|
||||||
return { r: f(0), g: f(8), b: f(4) }
|
return { r: f(0), g: f(8), b: f(4) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert RGB to string format for CSS variable
|
// Convert RGB to string format for CSS variable
|
||||||
function rgbToString(r: number, g: number, b: number): string {
|
function rgbToString(r: number, g: number, b: number): string {
|
||||||
return `${r}, ${g}, ${b}`
|
return `${r}, ${g}, ${b}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate color palette from base color (returns RGB strings)
|
// Generate color palette from base color (returns RGB strings)
|
||||||
function generatePalette(baseHex: string): ColorPalette {
|
function generatePalette(baseHex: string): ColorPalette {
|
||||||
const { h, s } = hexToHsl(baseHex)
|
const { h, s } = hexToHsl(baseHex);
|
||||||
|
|
||||||
// Lightness values for each shade level (from light to dark)
|
// Lightness values for each shade level (from light to dark)
|
||||||
const lightnessMap: Record<number, number> = {
|
const lightnessMap: Record<number, number> = {
|
||||||
@@ -85,118 +85,151 @@ function generatePalette(baseHex: string): ColorPalette {
|
|||||||
800: 26,
|
800: 26,
|
||||||
900: 18,
|
900: 18,
|
||||||
950: 10,
|
950: 10,
|
||||||
}
|
};
|
||||||
|
|
||||||
const palette: Partial<ColorPalette> = {}
|
const palette: Partial<ColorPalette> = {};
|
||||||
|
|
||||||
for (const shade of SHADE_LEVELS) {
|
for (const shade of SHADE_LEVELS) {
|
||||||
const lightness = lightnessMap[shade]
|
const lightness = lightnessMap[shade];
|
||||||
// Adjust saturation slightly for very light/dark shades
|
// Adjust saturation slightly for very light/dark shades
|
||||||
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s
|
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s;
|
||||||
const { r, g, b } = hslToRgb(h, adjustedS, lightness)
|
const { r, g, b } = hslToRgb(h, adjustedS, lightness);
|
||||||
palette[shade] = rgbToString(r, g, b)
|
palette[shade] = rgbToString(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
return palette as ColorPalette
|
return palette as ColorPalette;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interpolate between two RGB colors
|
// Interpolate between two RGB colors
|
||||||
function interpolateRgb(
|
function interpolateRgb(
|
||||||
rgb1: { r: number; g: number; b: number },
|
rgb1: { r: number; g: number; b: number },
|
||||||
rgb2: { r: number; g: number; b: number },
|
rgb2: { r: number; g: number; b: number },
|
||||||
factor: number
|
factor: number,
|
||||||
): string {
|
): string {
|
||||||
return rgbToString(
|
return rgbToString(
|
||||||
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
||||||
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
||||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
|
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||||
export function applyThemeColors(colors: ThemeColors): void {
|
export function applyThemeColors(colors: ThemeColors): void {
|
||||||
const root = document.documentElement
|
const root = document.documentElement;
|
||||||
|
|
||||||
// Generate palettes from status colors
|
// Generate palettes from status colors
|
||||||
const accentPalette = generatePalette(colors.accent)
|
const accentPalette = generatePalette(colors.accent);
|
||||||
const successPalette = generatePalette(colors.success)
|
const successPalette = generatePalette(colors.success);
|
||||||
const warningPalette = generatePalette(colors.warning)
|
const warningPalette = generatePalette(colors.warning);
|
||||||
const errorPalette = generatePalette(colors.error)
|
const errorPalette = generatePalette(colors.error);
|
||||||
|
|
||||||
// === DARK THEME PALETTE ===
|
// === DARK THEME PALETTE ===
|
||||||
// Convert hex colors to RGB
|
// Convert hex colors to RGB
|
||||||
const darkBgRgb = hexToRgb(colors.darkBackground)
|
const darkBgRgb = hexToRgb(colors.darkBackground);
|
||||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
|
const darkSurfaceRgb = hexToRgb(colors.darkSurface);
|
||||||
const darkTextRgb = hexToRgb(colors.darkText)
|
const darkTextRgb = hexToRgb(colors.darkText);
|
||||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
|
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
|
||||||
|
|
||||||
// Apply dark palette with actual user colors:
|
// Apply dark palette with actual user colors:
|
||||||
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
||||||
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
root.style.setProperty(
|
||||||
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
'--color-dark-50',
|
||||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
|
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
|
||||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
|
);
|
||||||
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
|
root.style.setProperty(
|
||||||
|
'--color-dark-100',
|
||||||
|
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
|
||||||
|
);
|
||||||
|
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33));
|
||||||
|
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
|
||||||
|
root.style.setProperty(
|
||||||
|
'--color-dark-400',
|
||||||
|
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
|
||||||
|
);
|
||||||
|
|
||||||
// Transition colors (500-700): interpolate between secondary text and surface
|
// Transition colors (500-700): interpolate between secondary text and surface
|
||||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
|
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
|
||||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
|
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
|
||||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
|
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
|
||||||
|
|
||||||
// Surface/card colors (800-850): surface color
|
// Surface/card colors (800-850): surface color
|
||||||
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
|
root.style.setProperty(
|
||||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
|
'--color-dark-800',
|
||||||
|
rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b),
|
||||||
|
);
|
||||||
|
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5));
|
||||||
|
|
||||||
// Background colors (900-950): background color
|
// Background colors (900-950): background color
|
||||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
|
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
|
||||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
|
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
|
||||||
|
|
||||||
// === LIGHT THEME PALETTE ===
|
// === LIGHT THEME PALETTE ===
|
||||||
const lightBgRgb = hexToRgb(colors.lightBackground)
|
const lightBgRgb = hexToRgb(colors.lightBackground);
|
||||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
|
const lightSurfaceRgb = hexToRgb(colors.lightSurface);
|
||||||
const lightTextRgb = hexToRgb(colors.lightText)
|
const lightTextRgb = hexToRgb(colors.lightText);
|
||||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
|
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary);
|
||||||
|
|
||||||
// Apply champagne palette with actual user colors:
|
// Apply champagne palette with actual user colors:
|
||||||
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
|
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
|
||||||
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
|
root.style.setProperty(
|
||||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
|
'--color-champagne-50',
|
||||||
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
|
rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b),
|
||||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
|
);
|
||||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
|
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3));
|
||||||
|
root.style.setProperty(
|
||||||
|
'--color-champagne-200',
|
||||||
|
rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b),
|
||||||
|
);
|
||||||
|
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
|
||||||
|
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
|
||||||
|
|
||||||
// Transition colors (500-600): between bg and text
|
// Transition colors (500-600): between bg and text
|
||||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
|
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
|
||||||
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
|
root.style.setProperty(
|
||||||
|
'--color-champagne-600',
|
||||||
|
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
|
||||||
|
);
|
||||||
|
|
||||||
// Text colors (700-950): secondary to primary text
|
// Text colors (700-950): secondary to primary text
|
||||||
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
|
root.style.setProperty(
|
||||||
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
|
'--color-champagne-700',
|
||||||
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33),
|
||||||
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
'--color-champagne-800',
|
||||||
|
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66),
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
'--color-champagne-900',
|
||||||
|
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
'--color-champagne-950',
|
||||||
|
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||||
|
);
|
||||||
|
|
||||||
// === STATUS COLOR PALETTES ===
|
// === STATUS COLOR PALETTES ===
|
||||||
for (const shade of SHADE_LEVELS) {
|
for (const shade of SHADE_LEVELS) {
|
||||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
|
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
|
||||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
|
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
|
||||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
|
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]);
|
||||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
|
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply semantic colors (hex for direct use)
|
// Apply semantic colors (hex for direct use)
|
||||||
root.style.setProperty('--color-dark-bg', colors.darkBackground)
|
root.style.setProperty('--color-dark-bg', colors.darkBackground);
|
||||||
root.style.setProperty('--color-dark-surface', colors.darkSurface)
|
root.style.setProperty('--color-dark-surface', colors.darkSurface);
|
||||||
root.style.setProperty('--color-dark-text', colors.darkText)
|
root.style.setProperty('--color-dark-text', colors.darkText);
|
||||||
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary)
|
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary);
|
||||||
|
|
||||||
root.style.setProperty('--color-light-bg', colors.lightBackground)
|
root.style.setProperty('--color-light-bg', colors.lightBackground);
|
||||||
root.style.setProperty('--color-light-surface', colors.lightSurface)
|
root.style.setProperty('--color-light-surface', colors.lightSurface);
|
||||||
root.style.setProperty('--color-light-text', colors.lightText)
|
root.style.setProperty('--color-light-text', colors.lightText);
|
||||||
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary)
|
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useThemeColors() {
|
export function useThemeColors() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: colors,
|
data: colors,
|
||||||
@@ -208,22 +241,22 @@ export function useThemeColors() {
|
|||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Apply colors when loaded or changed
|
// Apply colors when loaded or changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const colorsToApply = colors || DEFAULT_THEME_COLORS
|
const colorsToApply = colors || DEFAULT_THEME_COLORS;
|
||||||
applyThemeColors(colorsToApply)
|
applyThemeColors(colorsToApply);
|
||||||
}, [colors])
|
}, [colors]);
|
||||||
|
|
||||||
const invalidateColors = () => {
|
const invalidateColors = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
colors: colors || DEFAULT_THEME_COLORS,
|
colors: colors || DEFAULT_THEME_COLORS,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
invalidateColors,
|
invalidateColors,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,156 +1,159 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string
|
type: string;
|
||||||
ticket_id?: number
|
ticket_id?: number;
|
||||||
message?: string
|
message?: string;
|
||||||
title?: string
|
title?: string;
|
||||||
user_id?: number
|
user_id?: number;
|
||||||
is_admin?: boolean
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseWebSocketOptions {
|
interface UseWebSocketOptions {
|
||||||
onMessage?: (message: WSMessage) => void
|
onMessage?: (message: WSMessage) => void;
|
||||||
onConnect?: () => void
|
onConnect?: () => void;
|
||||||
onDisconnect?: () => void
|
onDisconnect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||||
const { accessToken, isAuthenticated } = useAuthStore()
|
const { accessToken, isAuthenticated } = useAuthStore();
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const reconnectAttemptsRef = useRef(0)
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const maxReconnectAttempts = 5
|
const maxReconnectAttempts = 5;
|
||||||
const optionsRef = useRef(options)
|
const optionsRef = useRef(options);
|
||||||
|
|
||||||
// Update options ref when they change
|
// Update options ref when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
optionsRef.current = options
|
optionsRef.current = options;
|
||||||
}, [options])
|
}, [options]);
|
||||||
|
|
||||||
const cleanup = useCallback(() => {
|
const cleanup = useCallback(() => {
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
reconnectTimeoutRef.current = null
|
reconnectTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close()
|
wsRef.current.close();
|
||||||
wsRef.current = null
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!accessToken || !isAuthenticated) {
|
if (!accessToken || !isAuthenticated) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't reconnect if already connected
|
// Don't reconnect if already connected
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup()
|
cleanup();
|
||||||
|
|
||||||
// Build WebSocket URL
|
// Build WebSocket URL
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
let host = window.location.host
|
let host = window.location.host;
|
||||||
|
|
||||||
// Handle VITE_API_URL - can be absolute URL or relative path
|
// Handle VITE_API_URL - can be absolute URL or relative path
|
||||||
const apiUrl = import.meta.env.VITE_API_URL
|
const apiUrl = import.meta.env.VITE_API_URL;
|
||||||
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
|
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
|
||||||
try {
|
try {
|
||||||
host = new URL(apiUrl).host
|
host = new URL(apiUrl).host;
|
||||||
} catch {
|
} catch {
|
||||||
// If URL parsing fails, use window.location.host
|
// If URL parsing fails, use window.location.host
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If apiUrl is relative (like /api), use window.location.host
|
// If apiUrl is relative (like /api), use window.location.host
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`
|
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (isDev) console.log('[WS] Connected')
|
if (isDev) console.log('[WS] Connected');
|
||||||
setIsConnected(true)
|
setIsConnected(true);
|
||||||
reconnectAttemptsRef.current = 0
|
reconnectAttemptsRef.current = 0;
|
||||||
optionsRef.current.onConnect?.()
|
optionsRef.current.onConnect?.();
|
||||||
|
|
||||||
// Setup ping interval (every 25 seconds)
|
// Setup ping interval (every 25 seconds)
|
||||||
pingIntervalRef.current = setInterval(() => {
|
pingIntervalRef.current = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'ping' }))
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
}
|
|
||||||
}, 25000)
|
|
||||||
}
|
}
|
||||||
|
}, 25000);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data) as WSMessage
|
const message = JSON.parse(event.data) as WSMessage;
|
||||||
|
|
||||||
// Ignore pong messages
|
// Ignore pong messages
|
||||||
if (message.type === 'pong' || message.type === 'connected') {
|
if (message.type === 'pong' || message.type === 'connected') {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
optionsRef.current.onMessage?.(message)
|
optionsRef.current.onMessage?.(message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isDev) console.error('[WS] Failed to parse message:', e)
|
if (isDev) console.error('[WS] Failed to parse message:', e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason)
|
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
|
||||||
setIsConnected(false)
|
setIsConnected(false);
|
||||||
optionsRef.current.onDisconnect?.()
|
optionsRef.current.onDisconnect?.();
|
||||||
|
|
||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to reconnect if not closed intentionally
|
// Attempt to reconnect if not closed intentionally
|
||||||
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||||
if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`)
|
if (isDev)
|
||||||
|
console.log(
|
||||||
|
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||||
|
);
|
||||||
|
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
reconnectAttemptsRef.current++
|
reconnectAttemptsRef.current++;
|
||||||
connect()
|
connect();
|
||||||
}, delay)
|
}, delay);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
if (isDev) console.error('[WS] Error:', error)
|
if (isDev) console.error('[WS] Error:', error);
|
||||||
}
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isDev) console.error('[WS] Failed to connect:', e)
|
if (isDev) console.error('[WS] Failed to connect:', e);
|
||||||
}
|
}
|
||||||
}, [accessToken, isAuthenticated, cleanup])
|
}, [accessToken, isAuthenticated, cleanup]);
|
||||||
|
|
||||||
// Connect when authenticated
|
// Connect when authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && accessToken) {
|
if (isAuthenticated && accessToken) {
|
||||||
connect()
|
connect();
|
||||||
} else {
|
} else {
|
||||||
cleanup()
|
cleanup();
|
||||||
setIsConnected(false)
|
setIsConnected(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup
|
return cleanup;
|
||||||
}, [isAuthenticated, accessToken, connect, cleanup])
|
}, [isAuthenticated, accessToken, connect, cleanup]);
|
||||||
|
|
||||||
return { isConnected }
|
return { isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/i18n.ts
20
src/i18n.ts
@@ -1,18 +1,18 @@
|
|||||||
import i18n from 'i18next'
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
import ru from './locales/ru.json'
|
import ru from './locales/ru.json';
|
||||||
import en from './locales/en.json'
|
import en from './locales/en.json';
|
||||||
import zh from './locales/zh.json'
|
import zh from './locales/zh.json';
|
||||||
import fa from './locales/fa.json'
|
import fa from './locales/fa.json';
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
ru: { translation: ru },
|
ru: { translation: ru },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
zh: { translation: zh },
|
zh: { translation: zh },
|
||||||
fa: { translation: fa },
|
fa: { translation: fa },
|
||||||
}
|
};
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@@ -35,6 +35,6 @@ i18n
|
|||||||
react: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
export default i18n
|
export default i18n;
|
||||||
|
|||||||
30
src/main.tsx
30
src/main.tsx
@@ -1,20 +1,20 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from './App'
|
import App from './App';
|
||||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
|
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
||||||
import { ToastProvider } from './components/Toast'
|
import { ToastProvider } from './components/Toast';
|
||||||
import { initLogoPreload } from './api/branding'
|
import { initLogoPreload } from './api/branding';
|
||||||
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
|
import { initTelegramWebApp } from './hooks/useTelegramWebApp';
|
||||||
import './i18n'
|
import './i18n';
|
||||||
import './styles/globals.css'
|
import './styles/globals.css';
|
||||||
|
|
||||||
// Initialize Telegram WebApp (expand, disable swipes)
|
// Initialize Telegram WebApp (expand, disable swipes)
|
||||||
initTelegramWebApp()
|
initTelegramWebApp();
|
||||||
|
|
||||||
// Preload logo from cache immediately on page load
|
// Preload logo from cache immediately on page load
|
||||||
initLogoPreload()
|
initLogoPreload();
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -23,7 +23,7 @@ const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
@@ -37,4 +37,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,81 +1,115 @@
|
|||||||
import { useState, useRef, useMemo } from 'react'
|
import { useState, useRef, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
adminBroadcastsApi,
|
adminBroadcastsApi,
|
||||||
Broadcast,
|
Broadcast,
|
||||||
BroadcastFilter,
|
BroadcastFilter,
|
||||||
TariffFilter,
|
TariffFilter,
|
||||||
BroadcastCreateRequest,
|
BroadcastCreateRequest,
|
||||||
} from '../api/adminBroadcasts'
|
} from '../api/adminBroadcasts';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const BroadcastIcon = () => (
|
const BroadcastIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const PlusIcon = () => (
|
const PlusIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const XIcon = () => (
|
const XIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const RefreshIcon = () => (
|
const RefreshIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const StopIcon = () => (
|
const StopIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const PhotoIcon = () => (
|
const PhotoIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const VideoIcon = () => (
|
const VideoIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const DocumentIcon = () => (
|
const DocumentIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UsersIcon = () => (
|
const UsersIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChevronDownIcon = () => (
|
const ChevronDownIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Status badge component
|
// Status badge component
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
@@ -87,13 +121,13 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
|
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
|
||||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
|
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
|
||||||
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
|
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
|
||||||
}
|
};
|
||||||
const config = statusConfig[status] || statusConfig.queued
|
const config = statusConfig[status] || statusConfig.queued;
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
|
<span className={`rounded-full px-2 py-1 text-xs font-medium ${config.bg} ${config.text}`}>
|
||||||
{config.label}
|
{config.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter labels
|
// Filter labels
|
||||||
@@ -105,201 +139,203 @@ const FILTER_GROUP_LABELS: Record<string, string> = {
|
|||||||
activity: 'По активности',
|
activity: 'По активности',
|
||||||
source: 'По источнику',
|
source: 'По источнику',
|
||||||
tariff: 'По тарифу',
|
tariff: 'По тарифу',
|
||||||
}
|
};
|
||||||
|
|
||||||
// Create broadcast modal
|
// Create broadcast modal
|
||||||
interface CreateModalProps {
|
interface CreateModalProps {
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSuccess: () => void
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [target, setTarget] = useState('')
|
const [target, setTarget] = useState('');
|
||||||
const [messageText, setMessageText] = useState('')
|
const [messageText, setMessageText] = useState('');
|
||||||
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home'])
|
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
|
||||||
const [mediaFile, setMediaFile] = useState<File | null>(null)
|
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||||
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo')
|
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
||||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null)
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null)
|
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
// Fetch filters
|
// Fetch filters
|
||||||
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
||||||
queryKey: ['admin', 'broadcasts', 'filters'],
|
queryKey: ['admin', 'broadcasts', 'filters'],
|
||||||
queryFn: adminBroadcastsApi.getFilters,
|
queryFn: adminBroadcastsApi.getFilters,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch buttons
|
// Fetch buttons
|
||||||
const { data: buttonsData } = useQuery({
|
const { data: buttonsData } = useQuery({
|
||||||
queryKey: ['admin', 'broadcasts', 'buttons'],
|
queryKey: ['admin', 'broadcasts', 'buttons'],
|
||||||
queryFn: adminBroadcastsApi.getButtons,
|
queryFn: adminBroadcastsApi.getButtons,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Preview mutation
|
// Preview mutation
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: adminBroadcastsApi.preview,
|
mutationFn: adminBroadcastsApi.preview,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Create mutation
|
// Create mutation
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: adminBroadcastsApi.create,
|
mutationFn: adminBroadcastsApi.create,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||||
onSuccess()
|
onSuccess();
|
||||||
onClose()
|
onClose();
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Group filters
|
// Group filters
|
||||||
const groupedFilters = useMemo(() => {
|
const groupedFilters = useMemo(() => {
|
||||||
if (!filtersData) return {}
|
if (!filtersData) return {};
|
||||||
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}
|
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
|
||||||
|
|
||||||
// Basic filters
|
// Basic filters
|
||||||
filtersData.filters.forEach(f => {
|
filtersData.filters.forEach((f) => {
|
||||||
const group = f.group || 'basic'
|
const group = f.group || 'basic';
|
||||||
if (!groups[group]) groups[group] = []
|
if (!groups[group]) groups[group] = [];
|
||||||
groups[group].push(f)
|
groups[group].push(f);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Tariff filters
|
// Tariff filters
|
||||||
if (filtersData.tariff_filters.length > 0) {
|
if (filtersData.tariff_filters.length > 0) {
|
||||||
groups['tariff'] = filtersData.tariff_filters
|
groups['tariff'] = filtersData.tariff_filters;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom filters
|
// Custom filters
|
||||||
filtersData.custom_filters.forEach(f => {
|
filtersData.custom_filters.forEach((f) => {
|
||||||
const group = f.group || 'custom'
|
const group = f.group || 'custom';
|
||||||
if (!groups[group]) groups[group] = []
|
if (!groups[group]) groups[group] = [];
|
||||||
groups[group].push(f)
|
groups[group].push(f);
|
||||||
})
|
});
|
||||||
|
|
||||||
return groups
|
return groups;
|
||||||
}, [filtersData])
|
}, [filtersData]);
|
||||||
|
|
||||||
// Selected filter info
|
// Selected filter info
|
||||||
const selectedFilter = useMemo(() => {
|
const selectedFilter = useMemo(() => {
|
||||||
if (!target || !filtersData) return null
|
if (!target || !filtersData) return null;
|
||||||
const all = [
|
const all = [
|
||||||
...filtersData.filters,
|
...filtersData.filters,
|
||||||
...filtersData.tariff_filters,
|
...filtersData.tariff_filters,
|
||||||
...filtersData.custom_filters,
|
...filtersData.custom_filters,
|
||||||
]
|
];
|
||||||
return all.find(f => f.key === target)
|
return all.find((f) => f.key === target);
|
||||||
}, [target, filtersData])
|
}, [target, filtersData]);
|
||||||
|
|
||||||
// Handle file selection
|
// Handle file selection
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0];
|
||||||
if (!file) return
|
if (!file) return;
|
||||||
|
|
||||||
setMediaFile(file)
|
setMediaFile(file);
|
||||||
|
|
||||||
// Determine media type
|
// Determine media type
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
setMediaType('photo')
|
setMediaType('photo');
|
||||||
setMediaPreview(URL.createObjectURL(file))
|
setMediaPreview(URL.createObjectURL(file));
|
||||||
} else if (file.type.startsWith('video/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
setMediaType('video')
|
setMediaType('video');
|
||||||
setMediaPreview(null)
|
setMediaPreview(null);
|
||||||
} else {
|
} else {
|
||||||
setMediaType('document')
|
setMediaType('document');
|
||||||
setMediaPreview(null)
|
setMediaPreview(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload file
|
// Upload file
|
||||||
setIsUploading(true)
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = await adminBroadcastsApi.uploadMedia(file, mediaType)
|
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
|
||||||
setUploadedFileId(result.file_id)
|
setUploadedFileId(result.file_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload failed:', err)
|
console.error('Upload failed:', err);
|
||||||
setMediaFile(null)
|
setMediaFile(null);
|
||||||
setMediaPreview(null)
|
setMediaPreview(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false)
|
setIsUploading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Remove media
|
// Remove media
|
||||||
const handleRemoveMedia = () => {
|
const handleRemoveMedia = () => {
|
||||||
setMediaFile(null)
|
setMediaFile(null);
|
||||||
setMediaPreview(null)
|
setMediaPreview(null);
|
||||||
setUploadedFileId(null)
|
setUploadedFileId(null);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = ''
|
fileInputRef.current.value = '';
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle button
|
// Toggle button
|
||||||
const toggleButton = (key: string) => {
|
const toggleButton = (key: string) => {
|
||||||
setSelectedButtons(prev =>
|
setSelectedButtons((prev) =>
|
||||||
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key]
|
prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Submit
|
// Submit
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!target || !messageText.trim()) return
|
if (!target || !messageText.trim()) return;
|
||||||
|
|
||||||
const data: BroadcastCreateRequest = {
|
const data: BroadcastCreateRequest = {
|
||||||
target,
|
target,
|
||||||
message_text: messageText,
|
message_text: messageText,
|
||||||
selected_buttons: selectedButtons,
|
selected_buttons: selectedButtons,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (uploadedFileId) {
|
if (uploadedFileId) {
|
||||||
data.media = {
|
data.media = {
|
||||||
type: mediaType,
|
type: mediaType,
|
||||||
file_id: uploadedFileId,
|
file_id: uploadedFileId,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
createMutation.mutate(data)
|
createMutation.mutate(data);
|
||||||
}
|
};
|
||||||
|
|
||||||
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null
|
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
|
||||||
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4">
|
<div className="my-4 w-full max-w-2xl rounded-xl bg-dark-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||||
<BroadcastIcon />
|
<BroadcastIcon />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
|
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4">
|
||||||
{/* Filter selection */}
|
{/* Filter selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.broadcasts.selectFilter')}
|
{t('admin.broadcasts.selectFilter')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors"
|
className="flex w-full items-center justify-between rounded-lg bg-dark-700 p-3 text-left transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UsersIcon />
|
<UsersIcon />
|
||||||
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
||||||
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')}
|
{selectedFilter
|
||||||
|
? selectedFilter.label
|
||||||
|
: t('admin.broadcasts.selectFilterPlaceholder')}
|
||||||
</span>
|
</span>
|
||||||
{recipientsCount !== null && (
|
{recipientsCount !== null && (
|
||||||
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs">
|
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||||
{recipientsCount} {t('admin.broadcasts.recipients')}
|
{recipientsCount} {t('admin.broadcasts.recipients')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -308,24 +344,24 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto">
|
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg bg-dark-700 shadow-xl">
|
||||||
{filtersLoading ? (
|
{filtersLoading ? (
|
||||||
<div className="p-4 text-center text-dark-400">Loading...</div>
|
<div className="p-4 text-center text-dark-400">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(groupedFilters).map(([group, filters]) => (
|
Object.entries(groupedFilters).map(([group, filters]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0">
|
<div className="sticky top-0 bg-dark-800 px-3 py-2 text-xs font-medium text-dark-400">
|
||||||
{FILTER_GROUP_LABELS[group] || group}
|
{FILTER_GROUP_LABELS[group] || group}
|
||||||
</div>
|
</div>
|
||||||
{filters.map(filter => (
|
{filters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
key={filter.key}
|
key={filter.key}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget(filter.key)
|
setTarget(filter.key);
|
||||||
setShowFilters(false)
|
setShowFilters(false);
|
||||||
previewMutation.mutate(filter.key)
|
previewMutation.mutate(filter.key);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${
|
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-600 ${
|
||||||
target === filter.key ? 'bg-accent-500/20' : ''
|
target === filter.key ? 'bg-accent-500/20' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -345,29 +381,27 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
|
|
||||||
{/* Message text */}
|
{/* Message text */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.broadcasts.messageText')}
|
{t('admin.broadcasts.messageText')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={messageText}
|
value={messageText}
|
||||||
onChange={e => setMessageText(e.target.value)}
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
||||||
rows={5}
|
rows={5}
|
||||||
maxLength={4000}
|
maxLength={4000}
|
||||||
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500"
|
className="w-full resize-none rounded-lg bg-dark-700 p-3 text-dark-100 placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-dark-400 mt-1 text-right">
|
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
|
||||||
{messageText.length}/4000
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Media upload */}
|
{/* Media upload */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.broadcasts.media')}
|
{t('admin.broadcasts.media')}
|
||||||
</label>
|
</label>
|
||||||
{mediaFile ? (
|
{mediaFile ? (
|
||||||
<div className="p-3 bg-dark-700 rounded-lg">
|
<div className="rounded-lg bg-dark-700 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{mediaType === 'photo' && <PhotoIcon />}
|
{mediaType === 'photo' && <PhotoIcon />}
|
||||||
@@ -382,7 +416,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleRemoveMedia}
|
onClick={handleRemoveMedia}
|
||||||
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400"
|
className="rounded-lg p-2 text-dark-400 hover:bg-dark-600 hover:text-red-400"
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
@@ -396,7 +430,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div>
|
<div className="mt-2 text-sm text-accent-400">
|
||||||
|
{t('admin.broadcasts.uploading')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -410,7 +446,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2"
|
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-dark-700 p-3 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
>
|
>
|
||||||
<PhotoIcon />
|
<PhotoIcon />
|
||||||
<span>{t('admin.broadcasts.addMedia')}</span>
|
<span>{t('admin.broadcasts.addMedia')}</span>
|
||||||
@@ -421,15 +457,15 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
|
|
||||||
{/* Buttons selection */}
|
{/* Buttons selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.broadcasts.buttons')}
|
{t('admin.broadcasts.buttons')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{buttonsData?.buttons.map(button => (
|
{buttonsData?.buttons.map((button) => (
|
||||||
<button
|
<button
|
||||||
key={button.key}
|
key={button.key}
|
||||||
onClick={() => toggleButton(button.key)}
|
onClick={() => toggleButton(button.key)}
|
||||||
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||||
selectedButtons.includes(button.key)
|
selectedButtons.includes(button.key)
|
||||||
? 'bg-accent-500 text-white'
|
? 'bg-accent-500 text-white'
|
||||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||||
@@ -443,76 +479,74 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between p-4 border-t border-dark-700">
|
<div className="flex items-center justify-between border-t border-dark-700 p-4">
|
||||||
<div className="text-sm text-dark-400">
|
<div className="text-sm text-dark-400">
|
||||||
{recipientsCount !== null && (
|
{recipientsCount !== null && (
|
||||||
<span>
|
<span>
|
||||||
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')}
|
{t('admin.broadcasts.willBeSent')}:{' '}
|
||||||
|
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
|
||||||
|
{t('admin.broadcasts.users')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors"
|
className="rounded-lg bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
|
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
|
||||||
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? (
|
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
|
||||||
<RefreshIcon />
|
|
||||||
) : (
|
|
||||||
<BroadcastIcon />
|
|
||||||
)}
|
|
||||||
{t('admin.broadcasts.send')}
|
{t('admin.broadcasts.send')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast detail modal
|
// Broadcast detail modal
|
||||||
interface DetailModalProps {
|
interface DetailModalProps {
|
||||||
broadcast: Broadcast
|
broadcast: Broadcast;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onStop: () => void
|
onStop: () => void;
|
||||||
isStopping: boolean
|
isStopping: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
|
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status)
|
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg">
|
<div className="w-full max-w-lg rounded-xl bg-dark-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<StatusBadge status={broadcast.status} />
|
<StatusBadge status={broadcast.status} />
|
||||||
<span className="text-dark-400">#{broadcast.id}</span>
|
<span className="text-dark-400">#{broadcast.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4 space-y-4">
|
<div className="space-y-4 p-4">
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="mb-1 flex justify-between text-sm">
|
||||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||||
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
|
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-accent-500 transition-all duration-300"
|
className="h-full bg-accent-500 transition-all duration-300"
|
||||||
style={{ width: `${broadcast.progress_percent}%` }}
|
style={{ width: `${broadcast.progress_percent}%` }}
|
||||||
@@ -523,15 +557,15 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
|||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
|
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
|
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
|
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
|
||||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
|
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
|
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
|
||||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
|
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,14 +573,14 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
|||||||
|
|
||||||
{/* Target */}
|
{/* Target */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p>
|
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
|
||||||
<p className="text-dark-100">{broadcast.target_type}</p>
|
<p className="text-dark-100">{broadcast.target_type}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p>
|
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.message')}</p>
|
||||||
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto">
|
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700 p-3 text-sm text-dark-100">
|
||||||
{broadcast.message_text}
|
{broadcast.message_text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +588,7 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
|||||||
{/* Media */}
|
{/* Media */}
|
||||||
{broadcast.has_media && (
|
{broadcast.has_media && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p>
|
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
|
||||||
<div className="flex items-center gap-2 text-dark-100">
|
<div className="flex items-center gap-2 text-dark-100">
|
||||||
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
||||||
{broadcast.media_type === 'video' && <VideoIcon />}
|
{broadcast.media_type === 'video' && <VideoIcon />}
|
||||||
@@ -573,11 +607,11 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{isRunning && broadcast.status !== 'cancelling' && (
|
{isRunning && broadcast.status !== 'cancelling' && (
|
||||||
<div className="p-4 border-t border-dark-700">
|
<div className="border-t border-dark-700 p-4">
|
||||||
<button
|
<button
|
||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
disabled={isStopping}
|
disabled={isStopping}
|
||||||
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500/20 py-2 text-red-400 transition-colors hover:bg-red-500/30 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<StopIcon />
|
<StopIcon />
|
||||||
{t('admin.broadcasts.stop')}
|
{t('admin.broadcasts.stop')}
|
||||||
@@ -586,48 +620,48 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component
|
// Main component
|
||||||
export default function AdminBroadcasts() {
|
export default function AdminBroadcasts() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null)
|
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null);
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0);
|
||||||
const limit = 20
|
const limit = 20;
|
||||||
|
|
||||||
// Fetch broadcasts
|
// Fetch broadcasts
|
||||||
const { data, isLoading, refetch } = useQuery({
|
const { data, isLoading, refetch } = useQuery({
|
||||||
queryKey: ['admin', 'broadcasts', 'list', page],
|
queryKey: ['admin', 'broadcasts', 'list', page],
|
||||||
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
|
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
|
||||||
refetchInterval: 5000, // Auto refresh every 5s
|
refetchInterval: 5000, // Auto refresh every 5s
|
||||||
})
|
});
|
||||||
|
|
||||||
// Stop mutation
|
// Stop mutation
|
||||||
const stopMutation = useMutation({
|
const stopMutation = useMutation({
|
||||||
mutationFn: adminBroadcastsApi.stop,
|
mutationFn: adminBroadcastsApi.stop,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||||
setSelectedBroadcast(null)
|
setSelectedBroadcast(null);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const broadcasts = data?.items || []
|
const broadcasts = data?.items || [];
|
||||||
const total = data?.total || 0
|
const total = data?.total || 0;
|
||||||
const totalPages = Math.ceil(total / limit)
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
|
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="mx-auto max-w-4xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -639,13 +673,13 @@ export default function AdminBroadcasts() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors"
|
className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
|
||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreateModal(true)}
|
onClick={() => setShowCreateModal(true)}
|
||||||
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2"
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
|
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
|
||||||
@@ -654,7 +688,7 @@ export default function AdminBroadcasts() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Broadcasts list */}
|
{/* Broadcasts list */}
|
||||||
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
<div className="overflow-hidden rounded-xl bg-dark-800">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-8 text-center text-dark-400">
|
<div className="p-8 text-center text-dark-400">
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
@@ -667,15 +701,15 @@ export default function AdminBroadcasts() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-dark-700">
|
<div className="divide-y divide-dark-700">
|
||||||
{broadcasts.map(broadcast => (
|
{broadcasts.map((broadcast) => (
|
||||||
<button
|
<button
|
||||||
key={broadcast.id}
|
key={broadcast.id}
|
||||||
onClick={() => setSelectedBroadcast(broadcast)}
|
onClick={() => setSelectedBroadcast(broadcast)}
|
||||||
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left"
|
className="w-full p-4 text-left transition-colors hover:bg-dark-700/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<StatusBadge status={broadcast.status} />
|
<StatusBadge status={broadcast.status} />
|
||||||
<span className="text-xs text-dark-400">#{broadcast.id}</span>
|
<span className="text-xs text-dark-400">#{broadcast.id}</span>
|
||||||
{broadcast.has_media && (
|
{broadcast.has_media && (
|
||||||
@@ -686,10 +720,8 @@ export default function AdminBroadcasts() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-dark-100 text-sm truncate">
|
<p className="truncate text-sm text-dark-100">{broadcast.message_text}</p>
|
||||||
{broadcast.message_text}
|
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
|
|
||||||
<span>{broadcast.target_type}</span>
|
<span>{broadcast.target_type}</span>
|
||||||
<span>
|
<span>
|
||||||
{broadcast.sent_count}/{broadcast.total_count}
|
{broadcast.sent_count}/{broadcast.total_count}
|
||||||
@@ -699,13 +731,13 @@ export default function AdminBroadcasts() {
|
|||||||
</div>
|
</div>
|
||||||
{['queued', 'in_progress'].includes(broadcast.status) && (
|
{['queued', 'in_progress'].includes(broadcast.status) && (
|
||||||
<div className="w-16">
|
<div className="w-16">
|
||||||
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden">
|
<div className="h-1.5 overflow-hidden rounded-full bg-dark-600">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-accent-500"
|
className="h-full bg-accent-500"
|
||||||
style={{ width: `${broadcast.progress_percent}%` }}
|
style={{ width: `${broadcast.progress_percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-dark-400 text-center mt-1">
|
<p className="mt-1 text-center text-xs text-dark-400">
|
||||||
{broadcast.progress_percent.toFixed(0)}%
|
{broadcast.progress_percent.toFixed(0)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -718,11 +750,11 @@ export default function AdminBroadcasts() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
|
<div className="flex items-center justify-center gap-2 border-t border-dark-700 p-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('common.prev')}
|
{t('common.prev')}
|
||||||
</button>
|
</button>
|
||||||
@@ -730,9 +762,9 @@ export default function AdminBroadcasts() {
|
|||||||
{page + 1} / {totalPages}
|
{page + 1} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={page >= totalPages - 1}
|
disabled={page >= totalPages - 1}
|
||||||
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('common.next')}
|
{t('common.next')}
|
||||||
</button>
|
</button>
|
||||||
@@ -746,7 +778,7 @@ export default function AdminBroadcasts() {
|
|||||||
<CreateBroadcastModal
|
<CreateBroadcastModal
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
refetch()
|
refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -760,5 +792,5 @@ export default function AdminBroadcasts() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1,93 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
adminEmailTemplatesApi,
|
adminEmailTemplatesApi,
|
||||||
EmailTemplateType,
|
EmailTemplateType,
|
||||||
EmailTemplateDetail,
|
EmailTemplateDetail,
|
||||||
EmailTemplateLanguageData,
|
EmailTemplateLanguageData,
|
||||||
} from '../api/adminEmailTemplates'
|
} from '../api/adminEmailTemplates';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const MailIcon = () => (
|
const MailIcon = () => (
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SaveIcon = () => (
|
const SaveIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const EyeIcon = () => (
|
const EyeIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SendIcon = () => (
|
const SendIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ResetIcon = () => (
|
const ResetIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const XIcon = () => (
|
const XIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const LANG_LABELS: Record<string, string> = {
|
const LANG_LABELS: Record<string, string> = {
|
||||||
ru: 'RU',
|
ru: 'RU',
|
||||||
en: 'EN',
|
en: 'EN',
|
||||||
zh: 'ZH',
|
zh: 'ZH',
|
||||||
ua: 'UA',
|
ua: 'UA',
|
||||||
}
|
};
|
||||||
|
|
||||||
const LANG_FULL_LABELS: Record<string, string> = {
|
const LANG_FULL_LABELS: Record<string, string> = {
|
||||||
ru: 'Русский',
|
ru: 'Русский',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
zh: '中文',
|
zh: '中文',
|
||||||
ua: 'Українська',
|
ua: 'Українська',
|
||||||
}
|
};
|
||||||
|
|
||||||
// ============ Template List View ============
|
// ============ Template List View ============
|
||||||
|
|
||||||
@@ -74,31 +96,31 @@ function TemplateCard({
|
|||||||
currentLang,
|
currentLang,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
template: EmailTemplateType
|
template: EmailTemplateType;
|
||||||
currentLang: string
|
currentLang: string;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const label = template.label[currentLang] || template.label['en'] || template.type
|
const label = template.label[currentLang] || template.label['en'] || template.type;
|
||||||
const description = template.description[currentLang] || template.description['en'] || ''
|
const description = template.description[currentLang] || template.description['en'] || '';
|
||||||
const customCount = Object.values(template.languages).filter(l => l.has_custom).length
|
const customCount = Object.values(template.languages).filter((l) => l.has_custom).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="w-full text-left p-3 sm:p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group"
|
className="group w-full rounded-xl border border-dark-700 bg-dark-800 p-3 text-left transition-all duration-200 hover:border-accent-500/50 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 sm:gap-3">
|
<div className="flex items-start justify-between gap-2 sm:gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
|
<h3 className="truncate text-sm font-medium text-dark-100 transition-colors group-hover:text-accent-400">
|
||||||
{label}
|
{label}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
|
<p className="mt-1 line-clamp-2 text-xs text-dark-400">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-1.5 flex-shrink-0 mt-0.5">
|
<div className="mt-0.5 flex flex-shrink-0 items-center gap-1 sm:gap-1.5">
|
||||||
{Object.entries(template.languages).map(([lang, status]) => (
|
{Object.entries(template.languages).map(([lang, status]) => (
|
||||||
<span
|
<span
|
||||||
key={lang}
|
key={lang}
|
||||||
className={`inline-flex items-center justify-center w-6 sm:w-7 h-5 rounded text-2xs font-medium ${
|
className={`inline-flex h-5 w-6 items-center justify-center rounded text-2xs font-medium sm:w-7 ${
|
||||||
status.has_custom
|
status.has_custom
|
||||||
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
|
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
: 'bg-dark-700 text-dark-400'
|
: 'bg-dark-700 text-dark-400'
|
||||||
@@ -112,14 +134,14 @@ function TemplateCard({
|
|||||||
</div>
|
</div>
|
||||||
{customCount > 0 && (
|
{customCount > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-2xs bg-accent-500/10 text-accent-400">
|
<span className="inline-flex items-center gap-1 rounded-full bg-accent-500/10 px-2 py-0.5 text-2xs text-accent-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-400" />
|
<span className="h-1.5 w-1.5 rounded-full bg-accent-400" />
|
||||||
{customCount} custom
|
{customCount} custom
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Template Editor ============
|
// ============ Template Editor ============
|
||||||
@@ -129,225 +151,249 @@ function TemplateEditor({
|
|||||||
onClose,
|
onClose,
|
||||||
currentLang: interfaceLang,
|
currentLang: interfaceLang,
|
||||||
}: {
|
}: {
|
||||||
detail: EmailTemplateDetail
|
detail: EmailTemplateDetail;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
currentLang: string
|
currentLang: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const [activeLang, setActiveLang] = useState('ru')
|
const [activeLang, setActiveLang] = useState('ru');
|
||||||
const [editSubject, setEditSubject] = useState('')
|
const [editSubject, setEditSubject] = useState('');
|
||||||
const [editBody, setEditBody] = useState('')
|
const [editBody, setEditBody] = useState('');
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [previewHtml, setPreviewHtml] = useState('')
|
const [previewHtml, setPreviewHtml] = useState('');
|
||||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]
|
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||||
|
|
||||||
// Load data for current language
|
// Load data for current language
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (langData) {
|
if (langData) {
|
||||||
setEditSubject(langData.subject)
|
setEditSubject(langData.subject);
|
||||||
setEditBody(langData.is_default ? langData.body_html : langData.body_html)
|
setEditBody(langData.is_default ? langData.body_html : langData.body_html);
|
||||||
setIsDirty(false)
|
setIsDirty(false);
|
||||||
}
|
}
|
||||||
}, [activeLang, langData])
|
}, [activeLang, langData]);
|
||||||
|
|
||||||
const showToast = useCallback((type: 'success' | 'error', message: string) => {
|
const showToast = useCallback((type: 'success' | 'error', message: string) => {
|
||||||
setToast({ type, message })
|
setToast({ type, message });
|
||||||
setTimeout(() => setToast(null), 3000)
|
setTimeout(() => setToast(null), 3000);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Extract body content from full HTML (strip base template wrapper)
|
// Extract body content from full HTML (strip base template wrapper)
|
||||||
const extractBodyContent = useCallback((html: string): string => {
|
const extractBodyContent = useCallback((html: string): string => {
|
||||||
// If it's wrapped in the base template, extract just the content div
|
// If it's wrapped in the base template, extract just the content div
|
||||||
const contentMatch = html.match(/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/)
|
const contentMatch = html.match(
|
||||||
|
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
|
||||||
|
);
|
||||||
if (contentMatch) {
|
if (contentMatch) {
|
||||||
return contentMatch[1].trim()
|
return contentMatch[1].trim();
|
||||||
}
|
}
|
||||||
return html
|
return html;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// When langData changes (e.g., after refetch), update the body content
|
// When langData changes (e.g., after refetch), update the body content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (langData) {
|
if (langData) {
|
||||||
if (langData.is_default) {
|
if (langData.is_default) {
|
||||||
// For default templates, extract just the content portion
|
// For default templates, extract just the content portion
|
||||||
setEditBody(extractBodyContent(langData.body_html))
|
setEditBody(extractBodyContent(langData.body_html));
|
||||||
} else {
|
} else {
|
||||||
setEditBody(langData.body_html)
|
setEditBody(langData.body_html);
|
||||||
}
|
}
|
||||||
setEditSubject(langData.subject)
|
setEditSubject(langData.subject);
|
||||||
setIsDirty(false)
|
setIsDirty(false);
|
||||||
}
|
}
|
||||||
}, [activeLang, langData, extractBodyContent])
|
}, [activeLang, langData, extractBodyContent]);
|
||||||
|
|
||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
|
mutationFn: () =>
|
||||||
|
adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
|
||||||
subject: editSubject,
|
subject: editSubject,
|
||||||
body_html: editBody,
|
body_html: editBody,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
|
queryClient.invalidateQueries({
|
||||||
setIsDirty(false)
|
queryKey: ['admin', 'email-template', detail.notification_type],
|
||||||
showToast('success', t('admin.emailTemplates.saved', 'Template saved'))
|
});
|
||||||
|
setIsDirty(false);
|
||||||
|
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast('error', t('common.error', 'Error'))
|
showToast('error', t('common.error', 'Error'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Reset mutation
|
// Reset mutation
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
|
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
|
queryClient.invalidateQueries({
|
||||||
setIsDirty(false)
|
queryKey: ['admin', 'email-template', detail.notification_type],
|
||||||
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'))
|
});
|
||||||
|
setIsDirty(false);
|
||||||
|
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast('error', t('common.error', 'Error'))
|
showToast('error', t('common.error', 'Error'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Preview mutation
|
// Preview mutation
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: () => adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
mutationFn: () =>
|
||||||
|
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
||||||
language: activeLang,
|
language: activeLang,
|
||||||
subject: editSubject,
|
subject: editSubject,
|
||||||
body_html: editBody,
|
body_html: editBody,
|
||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setPreviewHtml(data.body_html)
|
setPreviewHtml(data.body_html);
|
||||||
setShowPreview(true)
|
setShowPreview(true);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast('error', t('common.error', 'Error'))
|
showToast('error', t('common.error', 'Error'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Send test mutation
|
// Send test mutation
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: () => adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
|
mutationFn: () =>
|
||||||
|
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
|
||||||
language: activeLang,
|
language: activeLang,
|
||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
showToast('success', `${t('admin.emailTemplates.testSent', 'Test email sent')} → ${data.sent_to}`)
|
showToast(
|
||||||
|
'success',
|
||||||
|
`${t('admin.emailTemplates.testSent', 'Test email sent')} → ${data.sent_to}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast('error', t('common.error', 'Error'))
|
showToast('error', t('common.error', 'Error'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Write preview HTML into iframe
|
// Write preview HTML into iframe
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showPreview && iframeRef.current && previewHtml) {
|
if (showPreview && iframeRef.current && previewHtml) {
|
||||||
const doc = iframeRef.current.contentDocument
|
const doc = iframeRef.current.contentDocument;
|
||||||
if (doc) {
|
if (doc) {
|
||||||
doc.open()
|
doc.open();
|
||||||
doc.write(previewHtml)
|
doc.write(previewHtml);
|
||||||
doc.close()
|
doc.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [showPreview, previewHtml])
|
}, [showPreview, previewHtml]);
|
||||||
|
|
||||||
const handleSubjectChange = (value: string) => {
|
const handleSubjectChange = (value: string) => {
|
||||||
setEditSubject(value)
|
setEditSubject(value);
|
||||||
setIsDirty(true)
|
setIsDirty(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleBodyChange = (value: string) => {
|
const handleBodyChange = (value: string) => {
|
||||||
setEditBody(value)
|
setEditBody(value);
|
||||||
setIsDirty(true)
|
setIsDirty(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
|
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
<div className="flex items-start justify-between gap-2 sm:items-center">
|
||||||
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0">
|
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-3">
|
||||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-0.5 flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-dark-700 sm:mt-0"
|
||||||
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
|
<h2 className="truncate text-base font-semibold text-dark-100 sm:text-lg">{label}</h2>
|
||||||
<p className="text-xs text-dark-400 line-clamp-2">
|
<p className="line-clamp-2 text-xs text-dark-400">
|
||||||
{detail.description[interfaceLang] || detail.description['en'] || ''}
|
{detail.description[interfaceLang] || detail.description['en'] || ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{langData && !langData.is_default && (
|
{langData && !langData.is_default && (
|
||||||
<span className="px-2 sm:px-2.5 py-1 rounded-full text-2xs sm:text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25 flex-shrink-0">
|
<span className="flex-shrink-0 rounded-full bg-accent-500/15 px-2 py-1 text-2xs font-medium text-accent-400 ring-1 ring-accent-500/25 sm:px-2.5 sm:text-xs">
|
||||||
Custom
|
Custom
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language tabs */}
|
{/* Language tabs */}
|
||||||
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto">
|
<div className="flex items-center gap-1 overflow-x-auto rounded-lg bg-dark-900 p-1">
|
||||||
{Object.keys(detail.languages).map(lang => {
|
{Object.keys(detail.languages).map((lang) => {
|
||||||
const isActive = lang === activeLang
|
const isActive = lang === activeLang;
|
||||||
const langInfo = detail.languages[lang]
|
const langInfo = detail.languages[lang];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
|
if (
|
||||||
setActiveLang(lang)
|
isDirty &&
|
||||||
|
!window.confirm(
|
||||||
|
t(
|
||||||
|
'admin.emailTemplates.unsavedWarning',
|
||||||
|
'Unsaved changes will be lost. Continue?',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setActiveLang(lang);
|
||||||
}}
|
}}
|
||||||
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1 sm:gap-1.5 whitespace-nowrap ${
|
className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-dark-700 text-dark-100 shadow-sm'
|
? 'bg-dark-700 text-dark-100 shadow-sm'
|
||||||
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800'
|
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
|
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
|
||||||
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
|
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
|
||||||
{!langInfo.is_default && (
|
{!langInfo.is_default && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" />
|
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-400" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subject */}
|
{/* Subject */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-dark-300 mb-1.5">
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
{t('admin.emailTemplates.subject', 'Subject')}
|
{t('admin.emailTemplates.subject', 'Subject')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editSubject}
|
value={editSubject}
|
||||||
onChange={e => handleSubjectChange(e.target.value)}
|
onChange={(e) => handleSubjectChange(e.target.value)}
|
||||||
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-sm text-dark-100 placeholder-dark-500 focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors"
|
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
|
||||||
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
|
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context variables hint */}
|
{/* Context variables hint */}
|
||||||
{detail.context_vars.length > 0 && (
|
{detail.context_vars.length > 0 && (
|
||||||
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
|
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||||
<p className="text-xs font-medium text-dark-300 mb-1.5">
|
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||||
{t('admin.emailTemplates.variables', 'Available Variables')}
|
{t('admin.emailTemplates.variables', 'Available Variables')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||||
{detail.context_vars.map(v => (
|
{detail.context_vars.map((v) => (
|
||||||
<code
|
<code
|
||||||
key={v}
|
key={v}
|
||||||
className="px-2 py-0.5 rounded bg-dark-700 text-accent-400 text-xs font-mono cursor-pointer hover:bg-dark-600 transition-colors"
|
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
|
||||||
title={t('admin.emailTemplates.clickToCopy', 'Click to copy')}
|
title={t('admin.emailTemplates.clickToCopy', 'Click to copy')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(`{${v}}`)
|
navigator.clipboard.writeText(`{${v}}`);
|
||||||
showToast('success', `Copied {${v}}`)
|
showToast('success', `Copied {${v}}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`{${v}}`}
|
{`{${v}}`}
|
||||||
@@ -359,30 +405,33 @@ function TemplateEditor({
|
|||||||
|
|
||||||
{/* Body HTML editor */}
|
{/* Body HTML editor */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-dark-300 mb-1.5">
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
{t('admin.emailTemplates.body', 'Body (HTML)')}
|
{t('admin.emailTemplates.body', 'Body (HTML)')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={editBody}
|
value={editBody}
|
||||||
onChange={e => handleBodyChange(e.target.value)}
|
onChange={(e) => handleBodyChange(e.target.value)}
|
||||||
rows={12}
|
rows={12}
|
||||||
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-xs sm:text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y min-h-[200px] sm:min-h-[300px]"
|
className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm"
|
||||||
placeholder="<h2>Title</h2><p>Content...</p>"
|
placeholder="<h2>Title</h2><p>Content...</p>"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<p className="text-2xs text-dark-500 mt-1">
|
<p className="mt-1 text-2xs text-dark-500">
|
||||||
{t('admin.emailTemplates.bodyHint', 'HTML content that will be wrapped in the base email template with header and footer.')}
|
{t(
|
||||||
|
'admin.emailTemplates.bodyHint',
|
||||||
|
'HTML content that will be wrapped in the base email template with header and footer.',
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
<div className="grid grid-cols-2 sm:flex gap-2">
|
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={() => saveMutation.mutate()}
|
||||||
disabled={!isDirty || saveMutation.isPending}
|
disabled={!isDirty || saveMutation.isPending}
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
|
||||||
>
|
>
|
||||||
<SaveIcon />
|
<SaveIcon />
|
||||||
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
|
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
|
||||||
@@ -391,35 +440,46 @@ function TemplateEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={() => previewMutation.mutate()}
|
onClick={() => previewMutation.mutate()}
|
||||||
disabled={previewMutation.isPending}
|
disabled={previewMutation.isPending}
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
|
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||||
>
|
>
|
||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
{t('admin.emailTemplates.preview', 'Preview')}
|
{t('admin.emailTemplates.preview', 'Preview')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:flex gap-2">
|
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => testMutation.mutate()}
|
onClick={() => testMutation.mutate()}
|
||||||
disabled={testMutation.isPending}
|
disabled={testMutation.isPending}
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
|
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')}
|
{testMutation.isPending
|
||||||
|
? t('common.loading', 'Loading...')
|
||||||
|
: t('admin.emailTemplates.sendTest', 'Send Test')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{langData && !langData.is_default && (
|
{langData && !langData.is_default && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
|
if (
|
||||||
resetMutation.mutate()
|
window.confirm(
|
||||||
|
t(
|
||||||
|
'admin.emailTemplates.resetConfirm',
|
||||||
|
'Reset this template to the default version?',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
resetMutation.mutate();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={resetMutation.isPending}
|
disabled={resetMutation.isPending}
|
||||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors sm:ml-auto"
|
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2"
|
||||||
>
|
>
|
||||||
<ResetIcon />
|
<ResetIcon />
|
||||||
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span>
|
<span className="truncate">
|
||||||
|
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -427,26 +487,26 @@ function TemplateEditor({
|
|||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className={`fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in text-center sm:text-left ${
|
<div
|
||||||
toast.type === 'success'
|
className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
|
||||||
? 'bg-emerald-500/90 text-white'
|
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
|
||||||
: 'bg-red-500/90 text-white'
|
}`}
|
||||||
}`}>
|
>
|
||||||
{toast.message}
|
{toast.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
|
||||||
<div className="bg-dark-800 rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] sm:max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
|
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
|
||||||
<div className="flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700">
|
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
|
||||||
<h3 className="text-base font-semibold text-dark-100">
|
<h3 className="text-base font-semibold text-dark-100">
|
||||||
{t('admin.emailTemplates.preview', 'Preview')}
|
{t('admin.emailTemplates.preview', 'Preview')}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPreview(false)}
|
onClick={() => setShowPreview(false)}
|
||||||
className="p-1.5 rounded-lg hover:bg-dark-700 transition-colors"
|
className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -454,7 +514,7 @@ function TemplateEditor({
|
|||||||
<div className="flex-1 overflow-hidden p-1">
|
<div className="flex-1 overflow-hidden p-1">
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
className="w-full h-full min-h-[50vh] sm:min-h-[400px] rounded-lg bg-white"
|
className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
title="Email Preview"
|
title="Email Preview"
|
||||||
/>
|
/>
|
||||||
@@ -463,48 +523,48 @@ function TemplateEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Main Page ============
|
// ============ Main Page ============
|
||||||
|
|
||||||
export default function AdminEmailTemplates() {
|
export default function AdminEmailTemplates() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation();
|
||||||
const currentLang = i18n.language || 'ru'
|
const currentLang = i18n.language || 'ru';
|
||||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch template types list
|
// Fetch template types list
|
||||||
const { data: typesData, isLoading: typesLoading } = useQuery({
|
const { data: typesData, isLoading: typesLoading } = useQuery({
|
||||||
queryKey: ['admin', 'email-templates'],
|
queryKey: ['admin', 'email-templates'],
|
||||||
queryFn: adminEmailTemplatesApi.getTemplateTypes,
|
queryFn: adminEmailTemplatesApi.getTemplateTypes,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch detail for selected type
|
// Fetch detail for selected type
|
||||||
const { data: detailData, isLoading: detailLoading } = useQuery({
|
const { data: detailData, isLoading: detailLoading } = useQuery({
|
||||||
queryKey: ['admin', 'email-template', selectedType],
|
queryKey: ['admin', 'email-template', selectedType],
|
||||||
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
|
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
|
||||||
enabled: !!selectedType,
|
enabled: !!selectedType,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
|
<div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="p-1.5 sm:p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700 flex-shrink-0"
|
className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
|
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
|
||||||
<div className="p-1.5 sm:p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400 flex-shrink-0">
|
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
|
||||||
<MailIcon />
|
<MailIcon />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate">
|
<h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">
|
||||||
{t('admin.emailTemplates.title', 'Email Templates')}
|
{t('admin.emailTemplates.title', 'Email Templates')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-dark-400 truncate">
|
<p className="truncate text-xs text-dark-400">
|
||||||
{t('admin.emailTemplates.description', 'Manage email notification templates')}
|
{t('admin.emailTemplates.description', 'Manage email notification templates')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,12 +584,12 @@ export default function AdminEmailTemplates() {
|
|||||||
{typesLoading ? (
|
{typesLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="h-20 bg-dark-800 rounded-xl animate-pulse" />
|
<div key={i} className="h-20 animate-pulse rounded-xl bg-dark-800" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3">
|
||||||
{typesData?.items.map(template => (
|
{typesData?.items.map((template) => (
|
||||||
<TemplateCard
|
<TemplateCard
|
||||||
key={template.type}
|
key={template.type}
|
||||||
template={template}
|
template={template}
|
||||||
@@ -545,9 +605,9 @@ export default function AdminEmailTemplates() {
|
|||||||
{/* Detail loading overlay */}
|
{/* Detail loading overlay */}
|
||||||
{selectedType && detailLoading && (
|
{selectedType && detailLoading && (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +1,298 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// Group header icons
|
// Group header icons
|
||||||
const AnalyticsGroupIcon = () => (
|
const AnalyticsGroupIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UsersGroupIcon = () => (
|
const UsersGroupIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const TariffsGroupIcon = () => (
|
const TariffsGroupIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const MarketingGroupIcon = () => (
|
const MarketingGroupIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SystemGroupIcon = () => (
|
const SystemGroupIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Modern icons with consistent styling
|
// Modern icons with consistent styling
|
||||||
const ChartBarIcon = () => (
|
const ChartBarIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const BanknotesIcon = () => (
|
const BanknotesIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UsersIcon = () => (
|
const UsersIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChatBubbleIcon = () => (
|
const ChatBubbleIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const NoSymbolIcon = () => (
|
const NoSymbolIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CreditCardIcon = () => (
|
const CreditCardIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const TicketIcon = () => (
|
const TicketIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const GiftIcon = () => (
|
const GiftIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const MegaphoneIcon = () => (
|
const MegaphoneIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const PaperAirplaneIcon = () => (
|
const PaperAirplaneIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SparklesIcon = () => (
|
const SparklesIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CogIcon = () => (
|
const CogIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||||
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const DevicePhoneMobileIcon = () => (
|
const DevicePhoneMobileIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ServerStackIcon = () => (
|
const ServerStackIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const EnvelopeIcon = () => (
|
const EnvelopeIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CubeTransparentIcon = () => (
|
const CubeTransparentIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
interface AdminItem {
|
interface AdminItem {
|
||||||
to: string
|
to: string;
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode;
|
||||||
title: string
|
title: string;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminGroup {
|
interface AdminGroup {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode;
|
||||||
gradient: string
|
gradient: string;
|
||||||
borderColor: string
|
borderColor: string;
|
||||||
iconBg: string
|
iconBg: string;
|
||||||
iconColor: string
|
iconColor: string;
|
||||||
items: AdminItem[]
|
items: AdminItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) {
|
function AdminCard({
|
||||||
|
to,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
iconBg,
|
||||||
|
iconColor,
|
||||||
|
}: AdminItem & { iconBg: string; iconColor: string }) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className="group flex items-center gap-3 p-3 rounded-xl bg-dark-800/40 border border-dark-700/50 hover:bg-dark-800/80 hover:border-dark-600 transition-all duration-200"
|
className="group flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-all duration-200 hover:border-dark-600 hover:bg-dark-800/80"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-10 w-10 rounded-lg ${iconBg} ${iconColor} flex shrink-0 items-center justify-center transition-transform group-hover:scale-105`}
|
||||||
>
|
>
|
||||||
<div className={`w-10 h-10 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
|
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3>
|
<h3 className="text-sm font-medium text-dark-100 transition-colors group-hover:text-white">
|
||||||
<p className="text-xs text-dark-500 truncate">{description}</p>
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="truncate text-xs text-dark-500">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-dark-600 group-hover:text-dark-400 transition-colors">
|
<div className="text-dark-600 transition-colors group-hover:text-dark-400">
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupSection({ group }: { group: AdminGroup }) {
|
function GroupSection({ group }: { group: AdminGroup }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-dark-700/50 overflow-hidden bg-dark-900/30 backdrop-blur">
|
<div className="overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/30 backdrop-blur">
|
||||||
{/* Group Header */}
|
{/* Group Header */}
|
||||||
<div className={`px-4 py-3 bg-gradient-to-r ${group.gradient} border-b ${group.borderColor}`}>
|
<div className={`bg-gradient-to-r px-4 py-3 ${group.gradient} border-b ${group.borderColor}`}>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}>
|
<div className={`rounded-lg p-1.5 ${group.iconBg} ${group.iconColor}`}>{group.icon}</div>
|
||||||
{group.icon}
|
|
||||||
</div>
|
|
||||||
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
|
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group Items */}
|
{/* Group Items */}
|
||||||
<div className="p-2 space-y-1.5">
|
<div className="space-y-1.5 p-2">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<AdminCard
|
<AdminCard key={item.to} {...item} iconBg={group.iconBg} iconColor={group.iconColor} />
|
||||||
key={item.to}
|
|
||||||
{...item}
|
|
||||||
iconBg={group.iconBg}
|
|
||||||
iconColor={group.iconColor}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const groups: AdminGroup[] = [
|
const groups: AdminGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -364,22 +452,22 @@ export default function AdminPanel() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-4">
|
<div className="animate-fade-in space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
||||||
<p className="text-sm text-dark-400 mt-1">{t('admin.panel.subtitle')}</p>
|
<p className="mt-1 text-sm text-dark-400">{t('admin.panel.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Groups Grid */}
|
{/* Groups Grid */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<GroupSection key={group.id} group={group} />
|
<GroupSection key={group.id} group={group} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -11,55 +11,69 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types'
|
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
||||||
|
|
||||||
// ============ Icons ============
|
// ============ Icons ============
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const GripIcon = () => (
|
const GripIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CloseIcon = () => (
|
const CloseIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SaveIcon = () => (
|
const SaveIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// ============ Method icon by type ============
|
// ============ Method icon by type ============
|
||||||
|
|
||||||
@@ -75,7 +89,7 @@ const METHOD_ICONS: Record<string, string> = {
|
|||||||
wata: '\uD83D\uDCA7',
|
wata: '\uD83D\uDCA7',
|
||||||
freekassa: '\uD83D\uDCB5',
|
freekassa: '\uD83D\uDCB5',
|
||||||
cloudpayments: '\u2601\uFE0F',
|
cloudpayments: '\u2601\uFE0F',
|
||||||
}
|
};
|
||||||
|
|
||||||
const METHOD_LABELS: Record<string, string> = {
|
const METHOD_LABELS: Record<string, string> = {
|
||||||
telegram_stars: 'Telegram Stars',
|
telegram_stars: 'Telegram Stars',
|
||||||
@@ -89,53 +103,56 @@ const METHOD_LABELS: Record<string, string> = {
|
|||||||
wata: 'WATA',
|
wata: 'WATA',
|
||||||
freekassa: 'Freekassa',
|
freekassa: 'Freekassa',
|
||||||
cloudpayments: 'CloudPayments',
|
cloudpayments: 'CloudPayments',
|
||||||
}
|
};
|
||||||
|
|
||||||
// ============ Sortable Card ============
|
// ============ Sortable Card ============
|
||||||
|
|
||||||
interface SortableCardProps {
|
interface SortableCardProps {
|
||||||
config: PaymentMethodConfig
|
config: PaymentMethodConfig;
|
||||||
onClick: () => void
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
attributes,
|
id: config.method_id,
|
||||||
listeners,
|
});
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: config.method_id })
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
zIndex: isDragging ? 50 : undefined,
|
zIndex: isDragging ? 50 : undefined,
|
||||||
opacity: isDragging ? 0.85 : 1,
|
opacity: isDragging ? 0.85 : 1,
|
||||||
}
|
};
|
||||||
|
|
||||||
const displayName = config.display_name || config.default_display_name
|
const displayName = config.display_name || config.default_display_name;
|
||||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'
|
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
|
||||||
|
|
||||||
// Build condition summary chips
|
// Build condition summary chips
|
||||||
const chips: string[] = []
|
const chips: string[] = [];
|
||||||
if (config.user_type_filter === 'telegram') chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'))
|
if (config.user_type_filter === 'telegram')
|
||||||
if (config.user_type_filter === 'email') chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'))
|
chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'));
|
||||||
if (config.first_topup_filter === 'yes') chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'))
|
if (config.user_type_filter === 'email')
|
||||||
if (config.first_topup_filter === 'no') chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'))
|
chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'));
|
||||||
|
if (config.first_topup_filter === 'yes')
|
||||||
|
chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'));
|
||||||
|
if (config.first_topup_filter === 'no')
|
||||||
|
chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'));
|
||||||
if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) {
|
if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) {
|
||||||
chips.push(`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`)
|
chips.push(
|
||||||
|
`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count enabled sub-options
|
// Count enabled sub-options
|
||||||
let subOptionsInfo = ''
|
let subOptionsInfo = '';
|
||||||
if (config.available_sub_options && config.sub_options) {
|
if (config.available_sub_options && config.sub_options) {
|
||||||
const enabledCount = config.available_sub_options.filter(o => config.sub_options?.[o.id] !== false).length
|
const enabledCount = config.available_sub_options.filter(
|
||||||
const totalCount = config.available_sub_options.length
|
(o) => config.sub_options?.[o.id] !== false,
|
||||||
|
).length;
|
||||||
|
const totalCount = config.available_sub_options.length;
|
||||||
if (enabledCount < totalCount) {
|
if (enabledCount < totalCount) {
|
||||||
subOptionsInfo = `${enabledCount}/${totalCount}`
|
subOptionsInfo = `${enabledCount}/${totalCount}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,49 +160,49 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`group flex items-center gap-3 p-4 rounded-xl border transition-all ${
|
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
||||||
isDragging
|
isDragging
|
||||||
? 'bg-dark-700/80 border-accent-500/50 shadow-xl shadow-accent-500/10'
|
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
|
||||||
: config.is_enabled
|
: config.is_enabled
|
||||||
? 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
|
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||||
: 'bg-dark-900/30 border-dark-800/50 opacity-60'
|
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="flex-shrink-0 p-1.5 rounded-lg text-dark-500 hover:text-dark-300 hover:bg-dark-700/50 cursor-grab active:cursor-grabbing touch-manipulation"
|
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||||
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
|
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
|
||||||
>
|
>
|
||||||
<GripIcon />
|
<GripIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Method icon */}
|
{/* Method icon */}
|
||||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onClick}>
|
<div className="min-w-0 flex-1 cursor-pointer" onClick={onClick}>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold text-dark-100 truncate">{displayName}</span>
|
<span className="truncate font-semibold text-dark-100">{displayName}</span>
|
||||||
{config.is_enabled ? (
|
{config.is_enabled ? (
|
||||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-success-500/15 text-success-400 border border-success-500/20">
|
<span className="flex-shrink-0 rounded-full border border-success-500/20 bg-success-500/15 px-2 py-0.5 text-xs text-success-400">
|
||||||
{t('admin.paymentMethods.enabled', 'Вкл')}
|
{t('admin.paymentMethods.enabled', 'Вкл')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-500 border border-dark-700/30">
|
<span className="flex-shrink-0 rounded-full border border-dark-700/30 bg-dark-700/50 px-2 py-0.5 text-xs text-dark-500">
|
||||||
{t('admin.paymentMethods.disabled', 'Выкл')}
|
{t('admin.paymentMethods.disabled', 'Выкл')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!config.is_provider_configured && (
|
{!config.is_provider_configured && (
|
||||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-warning-500/15 text-warning-400 border border-warning-500/20">
|
<span className="flex-shrink-0 rounded-full border border-warning-500/20 bg-warning-500/15 px-2 py-0.5 text-xs text-warning-400">
|
||||||
{t('admin.paymentMethods.notConfigured', 'Не настроен')}
|
{t('admin.paymentMethods.notConfigured', 'Не настроен')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{subOptionsInfo && (
|
{subOptionsInfo && (
|
||||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-400">
|
<span className="flex-shrink-0 rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
|
||||||
{subOptionsInfo}
|
{subOptionsInfo}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -193,9 +210,12 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
|
|
||||||
{/* Condition chips */}
|
{/* Condition chips */}
|
||||||
{chips.length > 0 && (
|
{chips.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
|
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
{chips.map((chip, i) => (
|
{chips.map((chip, i) => (
|
||||||
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-accent-500/10 text-accent-400 border border-accent-500/15">
|
<span
|
||||||
|
key={i}
|
||||||
|
className="rounded-md border border-accent-500/15 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400"
|
||||||
|
>
|
||||||
{chip}
|
{chip}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -206,54 +226,64 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
|||||||
{/* Chevron */}
|
{/* Chevron */}
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="flex-shrink-0 p-1 text-dark-500 hover:text-dark-300 transition-colors"
|
className="flex-shrink-0 p-1 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon />
|
<ChevronRightIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Detail Modal ============
|
// ============ Detail Modal ============
|
||||||
|
|
||||||
interface DetailModalProps {
|
interface DetailModalProps {
|
||||||
config: PaymentMethodConfig
|
config: PaymentMethodConfig;
|
||||||
promoGroups: PromoGroupSimple[]
|
promoGroups: PromoGroupSimple[];
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSave: (methodId: string, data: Record<string, unknown>) => void
|
onSave: (methodId: string, data: Record<string, unknown>) => void;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSaving }: DetailModalProps) {
|
function PaymentMethodDetailModal({
|
||||||
const { t } = useTranslation()
|
config,
|
||||||
const displayName = config.display_name || config.default_display_name
|
promoGroups,
|
||||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: DetailModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayName = config.display_name || config.default_display_name;
|
||||||
|
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
|
||||||
|
|
||||||
// Local state for editing
|
// Local state for editing
|
||||||
const [isEnabled, setIsEnabled] = useState(config.is_enabled)
|
const [isEnabled, setIsEnabled] = useState(config.is_enabled);
|
||||||
const [customName, setCustomName] = useState(config.display_name || '')
|
const [customName, setCustomName] = useState(config.display_name || '');
|
||||||
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {})
|
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {});
|
||||||
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '')
|
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '');
|
||||||
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '')
|
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '');
|
||||||
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter)
|
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter);
|
||||||
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter)
|
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter);
|
||||||
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode)
|
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode);
|
||||||
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(config.allowed_promo_group_ids)
|
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(
|
||||||
|
config.allowed_promo_group_ids,
|
||||||
|
);
|
||||||
|
|
||||||
// Escape to close
|
// Escape to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose();
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose])
|
}, [onClose]);
|
||||||
|
|
||||||
// Scroll lock
|
// Scroll lock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden';
|
||||||
return () => { document.body.style.overflow = '' }
|
return () => {
|
||||||
}, [])
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
@@ -262,129 +292,148 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
|||||||
first_topup_filter: firstTopupFilter,
|
first_topup_filter: firstTopupFilter,
|
||||||
promo_group_filter_mode: promoGroupFilterMode,
|
promo_group_filter_mode: promoGroupFilterMode,
|
||||||
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
||||||
}
|
};
|
||||||
|
|
||||||
// Display name
|
// Display name
|
||||||
if (customName.trim()) {
|
if (customName.trim()) {
|
||||||
data.display_name = customName.trim()
|
data.display_name = customName.trim();
|
||||||
} else {
|
} else {
|
||||||
data.reset_display_name = true
|
data.reset_display_name = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-options
|
// Sub-options
|
||||||
if (config.available_sub_options) {
|
if (config.available_sub_options) {
|
||||||
data.sub_options = subOptions
|
data.sub_options = subOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Amounts
|
// Amounts
|
||||||
if (minAmount.trim()) {
|
if (minAmount.trim()) {
|
||||||
data.min_amount_kopeks = parseInt(minAmount, 10) || null
|
data.min_amount_kopeks = parseInt(minAmount, 10) || null;
|
||||||
} else {
|
} else {
|
||||||
data.reset_min_amount = true
|
data.reset_min_amount = true;
|
||||||
}
|
}
|
||||||
if (maxAmount.trim()) {
|
if (maxAmount.trim()) {
|
||||||
data.max_amount_kopeks = parseInt(maxAmount, 10) || null
|
data.max_amount_kopeks = parseInt(maxAmount, 10) || null;
|
||||||
} else {
|
} else {
|
||||||
data.reset_max_amount = true
|
data.reset_max_amount = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(config.method_id, data)
|
onSave(config.method_id, data);
|
||||||
}
|
};
|
||||||
|
|
||||||
const togglePromoGroup = (id: number) => {
|
const togglePromoGroup = (id: number) => {
|
||||||
setSelectedPromoGroupIds(prev =>
|
setSelectedPromoGroupIds((prev) =>
|
||||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center" onClick={onClose}>
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto m-4 rounded-2xl bg-dark-800 border border-dark-700 shadow-2xl"
|
className="relative m-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 z-10 flex items-center justify-between p-5 border-b border-dark-700 bg-dark-800 rounded-t-2xl">
|
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-2xl border-b border-dark-700 bg-dark-800 p-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
|
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
|
||||||
<p className="text-xs text-dark-500">{METHOD_LABELS[config.method_id] || config.method_id}</p>
|
<p className="text-xs text-dark-500">
|
||||||
|
{METHOD_LABELS[config.method_id] || config.method_id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors">
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 space-y-6">
|
<div className="space-y-6 p-5">
|
||||||
{/* Enable toggle */}
|
{/* Enable toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-dark-200">{t('admin.paymentMethods.methodEnabled', 'Метод включён')}</div>
|
<div className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.paymentMethods.methodEnabled', 'Метод включён')}
|
||||||
|
</div>
|
||||||
{!config.is_provider_configured && (
|
{!config.is_provider_configured && (
|
||||||
<div className="text-xs text-warning-400 mt-0.5">{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}</div>
|
<div className="mt-0.5 text-xs text-warning-400">
|
||||||
|
{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEnabled(!isEnabled)}
|
onClick={() => setIsEnabled(!isEnabled)}
|
||||||
className={`relative w-12 h-7 rounded-full transition-colors ${
|
className={`relative h-7 w-12 rounded-full transition-colors ${
|
||||||
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition-transform ${
|
<span
|
||||||
|
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
||||||
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
||||||
}`} />
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display name */}
|
{/* Display name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
{t('admin.paymentMethods.displayName', 'Отображаемое имя')}
|
{t('admin.paymentMethods.displayName', 'Отображаемое имя')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customName}
|
value={customName}
|
||||||
onChange={e => setCustomName(e.target.value)}
|
onChange={(e) => setCustomName(e.target.value)}
|
||||||
placeholder={config.default_display_name}
|
placeholder={config.default_display_name}
|
||||||
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-dark-500 mt-1">
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}: {config.default_display_name}
|
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}:{' '}
|
||||||
|
{config.default_display_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-options */}
|
{/* Sub-options */}
|
||||||
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
|
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{config.available_sub_options.map(opt => {
|
{config.available_sub_options.map((opt) => {
|
||||||
const enabled = subOptions[opt.id] !== false
|
const enabled = subOptions[opt.id] !== false;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
onClick={() => setSubOptions(prev => ({ ...prev, [opt.id]: !enabled }))}
|
onClick={() => setSubOptions((prev) => ({ ...prev, [opt.id]: !enabled }))}
|
||||||
className={`w-full flex items-center justify-between p-3 rounded-xl border transition-all ${
|
className={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
|
||||||
enabled
|
enabled
|
||||||
? 'bg-dark-700/30 border-accent-500/30 text-dark-100'
|
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
|
||||||
: 'bg-dark-900/30 border-dark-800 text-dark-500'
|
: 'border-dark-800 bg-dark-900/30 text-dark-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm">{opt.name}</span>
|
<span className="text-sm">{opt.name}</span>
|
||||||
<div className={`w-5 h-5 rounded flex items-center justify-center ${
|
<div
|
||||||
enabled ? 'bg-accent-500 text-white' : 'bg-dark-700 border border-dark-600'
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
}`}>
|
enabled
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'border border-dark-600 bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{enabled && <CheckIcon />}
|
{enabled && <CheckIcon />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -393,55 +442,58 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
|||||||
{/* Min/Max amounts */}
|
{/* Min/Max amounts */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
|
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={minAmount}
|
value={minAmount}
|
||||||
onChange={e => setMinAmount(e.target.value)}
|
onChange={(e) => setMinAmount(e.target.value)}
|
||||||
placeholder={config.default_min_amount_kopeks.toString()}
|
placeholder={config.default_min_amount_kopeks.toString()}
|
||||||
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||||
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
|
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={maxAmount}
|
value={maxAmount}
|
||||||
onChange={e => setMaxAmount(e.target.value)}
|
onChange={(e) => setMaxAmount(e.target.value)}
|
||||||
placeholder={config.default_max_amount_kopeks.toString()}
|
placeholder={config.default_max_amount_kopeks.toString()}
|
||||||
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display conditions */}
|
{/* Display conditions */}
|
||||||
<div className="pt-3 border-t border-dark-700">
|
<div className="border-t border-dark-700 pt-3">
|
||||||
<h3 className="text-sm font-semibold text-dark-200 mb-4">
|
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
||||||
{t('admin.paymentMethods.conditions', 'Условия отображения')}
|
{t('admin.paymentMethods.conditions', 'Условия отображения')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* User type filter */}
|
{/* User type filter */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm text-dark-300 mb-2">
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
|
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(['all', 'telegram', 'email'] as const).map(val => (
|
{(['all', 'telegram', 'email'] as const).map((val) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => setUserTypeFilter(val)}
|
onClick={() => setUserTypeFilter(val)}
|
||||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
userTypeFilter === val
|
userTypeFilter === val
|
||||||
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{val === 'all' ? t('admin.paymentMethods.userTypeAll', 'Все') :
|
{val === 'all'
|
||||||
val === 'telegram' ? 'Telegram' : 'Email'}
|
? t('admin.paymentMethods.userTypeAll', 'Все')
|
||||||
|
: val === 'telegram'
|
||||||
|
? 'Telegram'
|
||||||
|
: 'Email'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -449,23 +501,25 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
|||||||
|
|
||||||
{/* First topup filter */}
|
{/* First topup filter */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm text-dark-300 mb-2">
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
|
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(['any', 'yes', 'no'] as const).map(val => (
|
{(['any', 'yes', 'no'] as const).map((val) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => setFirstTopupFilter(val)}
|
onClick={() => setFirstTopupFilter(val)}
|
||||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
firstTopupFilter === val
|
firstTopupFilter === val
|
||||||
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{val === 'any' ? t('admin.paymentMethods.firstTopupAny', 'Не важно') :
|
{val === 'any'
|
||||||
val === 'yes' ? t('admin.paymentMethods.firstTopupYes', 'Было') :
|
? t('admin.paymentMethods.firstTopupAny', 'Не важно')
|
||||||
t('admin.paymentMethods.firstTopupNo', 'Не было')}
|
: val === 'yes'
|
||||||
|
? t('admin.paymentMethods.firstTopupYes', 'Было')
|
||||||
|
: t('admin.paymentMethods.firstTopupNo', 'Не было')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -473,55 +527,56 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
|||||||
|
|
||||||
{/* Promo groups filter */}
|
{/* Promo groups filter */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-2">
|
<label className="mb-2 block text-sm text-dark-300">
|
||||||
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
|
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 mb-3">
|
<div className="mb-3 flex gap-2">
|
||||||
{(['all', 'selected'] as const).map(val => (
|
{(['all', 'selected'] as const).map((val) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => setPromoGroupFilterMode(val)}
|
onClick={() => setPromoGroupFilterMode(val)}
|
||||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||||
promoGroupFilterMode === val
|
promoGroupFilterMode === val
|
||||||
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
|
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||||
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
|
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{val === 'all'
|
{val === 'all'
|
||||||
? t('admin.paymentMethods.promoGroupAll', 'Все группы')
|
? t('admin.paymentMethods.promoGroupAll', 'Все группы')
|
||||||
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')
|
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')}
|
||||||
}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{promoGroupFilterMode === 'selected' && (
|
{promoGroupFilterMode === 'selected' && (
|
||||||
<div className="max-h-48 overflow-y-auto space-y-1.5 p-3 rounded-xl bg-dark-900/30 border border-dark-700/50">
|
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
||||||
{promoGroups.length === 0 ? (
|
{promoGroups.length === 0 ? (
|
||||||
<p className="text-sm text-dark-500 text-center py-2">
|
<p className="py-2 text-center text-sm text-dark-500">
|
||||||
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
|
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
promoGroups.map(group => {
|
promoGroups.map((group) => {
|
||||||
const selected = selectedPromoGroupIds.includes(group.id)
|
const selected = selectedPromoGroupIds.includes(group.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={group.id}
|
key={group.id}
|
||||||
onClick={() => togglePromoGroup(group.id)}
|
onClick={() => togglePromoGroup(group.id)}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
|
||||||
selected
|
selected
|
||||||
? 'bg-accent-500/15 text-accent-300'
|
? 'bg-accent-500/15 text-accent-300'
|
||||||
: 'text-dark-400 hover:bg-dark-800/50'
|
: 'text-dark-400 hover:bg-dark-800/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{group.name}</span>
|
<span>{group.name}</span>
|
||||||
<div className={`w-4 h-4 rounded flex items-center justify-center ${
|
<div
|
||||||
|
className={`flex h-4 w-4 items-center justify-center rounded ${
|
||||||
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{selected && <CheckIcon />}
|
{selected && <CheckIcon />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -531,20 +586,20 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="sticky bottom-0 flex items-center gap-3 p-5 border-t border-dark-700 bg-dark-800 rounded-b-2xl">
|
<div className="sticky bottom-0 flex items-center gap-3 rounded-b-2xl border-t border-dark-700 bg-dark-800 p-5">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors font-medium"
|
className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
>
|
>
|
||||||
{t('common.cancel', 'Отмена')}
|
{t('common.cancel', 'Отмена')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-50 transition-colors font-medium flex items-center justify-center gap-2"
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<SaveIcon />
|
<SaveIcon />
|
||||||
)}
|
)}
|
||||||
@@ -553,126 +608,130 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Toast ============
|
// ============ Toast ============
|
||||||
|
|
||||||
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(onClose, 3000)
|
const timer = setTimeout(onClose, 3000);
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer);
|
||||||
}, [onClose])
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-xl bg-success-500/90 text-white text-sm font-medium shadow-lg backdrop-blur-sm animate-fade-in flex items-center gap-2">
|
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-xl bg-success-500/90 px-5 py-3 text-sm font-medium text-white shadow-lg backdrop-blur-sm">
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Main Page ============
|
// ============ Main Page ============
|
||||||
|
|
||||||
export default function AdminPaymentMethods() {
|
export default function AdminPaymentMethods() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [methods, setMethods] = useState<PaymentMethodConfig[]>([])
|
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null)
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null);
|
||||||
const [orderChanged, setOrderChanged] = useState(false)
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch payment methods
|
// Fetch payment methods
|
||||||
const { data: fetchedMethods, isLoading } = useQuery({
|
const { data: fetchedMethods, isLoading } = useQuery({
|
||||||
queryKey: ['admin-payment-methods'],
|
queryKey: ['admin-payment-methods'],
|
||||||
queryFn: adminPaymentMethodsApi.getAll,
|
queryFn: adminPaymentMethodsApi.getAll,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch promo groups
|
// Fetch promo groups
|
||||||
const { data: promoGroups = [] } = useQuery({
|
const { data: promoGroups = [] } = useQuery({
|
||||||
queryKey: ['admin-payment-methods-promo-groups'],
|
queryKey: ['admin-payment-methods-promo-groups'],
|
||||||
queryFn: adminPaymentMethodsApi.getPromoGroups,
|
queryFn: adminPaymentMethodsApi.getPromoGroups,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Sync fetched data to local state
|
// Sync fetched data to local state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchedMethods && !orderChanged) {
|
if (fetchedMethods && !orderChanged) {
|
||||||
setMethods(fetchedMethods)
|
setMethods(fetchedMethods);
|
||||||
}
|
}
|
||||||
}, [fetchedMethods, orderChanged])
|
}, [fetchedMethods, orderChanged]);
|
||||||
|
|
||||||
// Save order mutation
|
// Save order mutation
|
||||||
const saveOrderMutation = useMutation({
|
const saveOrderMutation = useMutation({
|
||||||
mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds),
|
mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setOrderChanged(false)
|
setOrderChanged(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||||
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'))
|
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setToastMessage(t('common.error', 'Ошибка'))
|
setToastMessage(t('common.error', 'Ошибка'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Update method mutation
|
// Update method mutation
|
||||||
const updateMethodMutation = useMutation({
|
const updateMethodMutation = useMutation({
|
||||||
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
|
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
|
||||||
adminPaymentMethodsApi.update(methodId, data),
|
adminPaymentMethodsApi.update(methodId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||||
setSelectedMethod(null)
|
setSelectedMethod(null);
|
||||||
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'))
|
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'));
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setToastMessage(t('common.error', 'Ошибка'))
|
setToastMessage(t('common.error', 'Ошибка'));
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// DnD sensors
|
// DnD sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
)
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event;
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
setMethods(prev => {
|
setMethods((prev) => {
|
||||||
const oldIndex = prev.findIndex(m => m.method_id === active.id)
|
const oldIndex = prev.findIndex((m) => m.method_id === active.id);
|
||||||
const newIndex = prev.findIndex(m => m.method_id === over.id)
|
const newIndex = prev.findIndex((m) => m.method_id === over.id);
|
||||||
if (oldIndex === -1 || newIndex === -1) return prev
|
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||||
return arrayMove(prev, oldIndex, newIndex)
|
return arrayMove(prev, oldIndex, newIndex);
|
||||||
})
|
});
|
||||||
setOrderChanged(true)
|
setOrderChanged(true);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleSaveOrder = () => {
|
const handleSaveOrder = () => {
|
||||||
saveOrderMutation.mutate(methods.map(m => m.method_id))
|
saveOrderMutation.mutate(methods.map((m) => m.method_id));
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
|
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
|
||||||
updateMethodMutation.mutate({ methodId, data })
|
updateMethodMutation.mutate({ methodId, data });
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCloseToast = useCallback(() => setToastMessage(null), [])
|
const handleCloseToast = useCallback(() => setToastMessage(null), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title', 'Платёжные методы')}</h1>
|
<h1 className="text-2xl font-bold text-dark-50">
|
||||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}</p>
|
{t('admin.paymentMethods.title', 'Платёжные методы')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{orderChanged && (
|
{orderChanged && (
|
||||||
@@ -682,7 +741,7 @@ export default function AdminPaymentMethods() {
|
|||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{saveOrderMutation.isPending ? (
|
{saveOrderMutation.isPending ? (
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<SaveIcon />
|
<SaveIcon />
|
||||||
)}
|
)}
|
||||||
@@ -692,22 +751,32 @@ export default function AdminPaymentMethods() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag hint */}
|
{/* Drag hint */}
|
||||||
<div className="text-sm text-dark-500 flex items-center gap-2">
|
<div className="flex items-center gap-2 text-sm text-dark-500">
|
||||||
<GripIcon />
|
<GripIcon />
|
||||||
{t('admin.paymentMethods.dragHint', 'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.')}
|
{t(
|
||||||
|
'admin.paymentMethods.dragHint',
|
||||||
|
'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.',
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Methods list */}
|
{/* Methods list */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : methods.length > 0 ? (
|
) : methods.length > 0 ? (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext
|
||||||
<SortableContext items={methods.map(m => m.method_id)} strategy={verticalListSortingStrategy}>
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={methods.map((m) => m.method_id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{methods.map(config => (
|
{methods.map((config) => (
|
||||||
<SortablePaymentCard
|
<SortablePaymentCard
|
||||||
key={config.method_id}
|
key={config.method_id}
|
||||||
config={config}
|
config={config}
|
||||||
@@ -718,11 +787,13 @@ export default function AdminPaymentMethods() {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||||
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
|
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-dark-400">{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}</div>
|
<div className="text-dark-400">
|
||||||
|
{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -739,9 +810,7 @@ export default function AdminPaymentMethods() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toast */}
|
{/* Toast */}
|
||||||
{toastMessage && (
|
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
|
||||||
<Toast message={toastMessage} onClose={handleCloseToast} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import { adminPaymentsApi } from '../api/adminPayments'
|
import { adminPaymentsApi } from '../api/adminPayments';
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import type { PendingPayment, PaginatedResponse } from '../types'
|
import type { PendingPayment, PaginatedResponse } from '../types';
|
||||||
|
|
||||||
export default function AdminPayments() {
|
export default function AdminPayments() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol } = useCurrency()
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
|
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1);
|
||||||
const [methodFilter, setMethodFilter] = useState<string>('')
|
const [methodFilter, setMethodFilter] = useState<string>('');
|
||||||
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null)
|
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch payments
|
// Fetch payments
|
||||||
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({
|
const {
|
||||||
|
data: payments,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<PaginatedResponse<PendingPayment>>({
|
||||||
queryKey: ['admin-payments', page, methodFilter],
|
queryKey: ['admin-payments', page, methodFilter],
|
||||||
queryFn: () => adminPaymentsApi.getPendingPayments({
|
queryFn: () =>
|
||||||
|
adminPaymentsApi.getPendingPayments({
|
||||||
page,
|
page,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
method_filter: methodFilter || undefined,
|
method_filter: methodFilter || undefined,
|
||||||
}),
|
}),
|
||||||
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||||
})
|
});
|
||||||
|
|
||||||
// Fetch stats
|
// Fetch stats
|
||||||
const { data: stats } = useQuery({
|
const { data: stats } = useQuery({
|
||||||
queryKey: ['admin-payments-stats'],
|
queryKey: ['admin-payments-stats'],
|
||||||
queryFn: adminPaymentsApi.getStats,
|
queryFn: adminPaymentsApi.getStats,
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Check payment mutation
|
// Check payment mutation
|
||||||
const checkPaymentMutation = useMutation({
|
const checkPaymentMutation = useMutation({
|
||||||
@@ -39,49 +44,66 @@ export default function AdminPayments() {
|
|||||||
adminPaymentsApi.checkPaymentStatus(method, paymentId),
|
adminPaymentsApi.checkPaymentStatus(method, paymentId),
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
if (result.status_changed) {
|
if (result.status_changed) {
|
||||||
await refetch()
|
await refetch();
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] });
|
||||||
} else {
|
} else {
|
||||||
await refetch()
|
await refetch();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
setCheckingPaymentId(null)
|
setCheckingPaymentId(null);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleCheckPayment = (payment: PendingPayment) => {
|
const handleCheckPayment = (payment: PendingPayment) => {
|
||||||
setCheckingPaymentId(`${payment.method}_${payment.id}`)
|
setCheckingPaymentId(`${payment.method}_${payment.id}`);
|
||||||
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id })
|
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id });
|
||||||
}
|
};
|
||||||
|
|
||||||
// Get unique methods from stats for filter
|
// Get unique methods from stats for filter
|
||||||
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []
|
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1>
|
<h1 className="text-2xl font-bold text-dark-50">
|
||||||
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p>
|
{t('admin.payments.title', 'Проверка платежей')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => refetch()} className="btn-secondary flex items-center gap-2">
|
||||||
onClick={() => refetch()}
|
<svg
|
||||||
className="btn-secondary flex items-center gap-2"
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<path
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('common.refresh', 'Обновить')}
|
{t('common.refresh', 'Обновить')}
|
||||||
</button>
|
</button>
|
||||||
@@ -89,18 +111,20 @@ export default function AdminPayments() {
|
|||||||
|
|
||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50">
|
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||||
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
|
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
|
||||||
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div>
|
<div className="text-sm text-dark-400">
|
||||||
|
{t('admin.payments.totalPending', 'Всего ожидает')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(stats.by_method).map(([method, count]) => (
|
{Object.entries(stats.by_method).map(([method, count]) => (
|
||||||
<div
|
<div
|
||||||
key={method}
|
key={method}
|
||||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
className={`cursor-pointer rounded-xl border p-4 transition-all ${
|
||||||
methodFilter === method
|
methodFilter === method
|
||||||
? 'bg-accent-500/20 border-accent-500/50'
|
? 'border-accent-500/50 bg-accent-500/20'
|
||||||
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
|
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
|
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
|
||||||
>
|
>
|
||||||
@@ -113,11 +137,13 @@ export default function AdminPayments() {
|
|||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
{methodOptions.length > 0 && (
|
{methodOptions.length > 0 && (
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span>
|
<span className="text-sm text-dark-400">
|
||||||
|
{t('admin.payments.filterByMethod', 'Фильтр по методу')}:
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMethodFilter('')}
|
onClick={() => setMethodFilter('')}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||||
methodFilter === ''
|
methodFilter === ''
|
||||||
? 'bg-accent-500 text-white'
|
? 'bg-accent-500 text-white'
|
||||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
@@ -129,7 +155,7 @@ export default function AdminPayments() {
|
|||||||
<button
|
<button
|
||||||
key={method}
|
key={method}
|
||||||
onClick={() => setMethodFilter(method)}
|
onClick={() => setMethodFilter(method)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||||
methodFilter === method
|
methodFilter === method
|
||||||
? 'bg-accent-500 text-white'
|
? 'bg-accent-500 text-white'
|
||||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
@@ -145,28 +171,30 @@ export default function AdminPayments() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : payments?.items && payments.items.length > 0 ? (
|
) : payments?.items && payments.items.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{payments.items.map((payment) => {
|
{payments.items.map((payment) => {
|
||||||
const paymentKey = `${payment.method}_${payment.id}`
|
const paymentKey = `${payment.method}_${payment.id}`;
|
||||||
const isChecking = checkingPaymentId === paymentKey
|
const isChecking = checkingPaymentId === paymentKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={paymentKey}
|
key={paymentKey}
|
||||||
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold text-dark-100">{payment.method_display}</span>
|
<span className="font-semibold text-dark-100">
|
||||||
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300">
|
{payment.method_display}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-sm text-dark-300">
|
||||||
{payment.status_emoji} {payment.status_text}
|
{payment.status_emoji} {payment.status_text}
|
||||||
</span>
|
</span>
|
||||||
{payment.is_paid && (
|
{payment.is_paid && (
|
||||||
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400">
|
<span className="rounded-full bg-success-500/20 px-2 py-0.5 text-sm text-success-400">
|
||||||
{t('admin.payments.paid', 'Оплачено')}
|
{t('admin.payments.paid', 'Оплачено')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -174,16 +202,18 @@ export default function AdminPayments() {
|
|||||||
<div className="text-lg font-semibold text-dark-50">
|
<div className="text-lg font-semibold text-dark-50">
|
||||||
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-dark-400 mt-1">
|
<div className="mt-1 text-sm text-dark-400">
|
||||||
ID: <code className="text-dark-300">{payment.identifier}</code>
|
ID: <code className="text-dark-300">{payment.identifier}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500 mt-1">
|
<div className="mt-1 text-xs text-dark-500">
|
||||||
{new Date(payment.created_at).toLocaleString()}
|
{new Date(payment.created_at).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
{(payment.user_username || payment.user_telegram_id) && (
|
{(payment.user_username || payment.user_telegram_id) && (
|
||||||
<div className="text-sm text-dark-400 mt-2">
|
<div className="mt-2 text-sm text-dark-400">
|
||||||
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '}
|
<span className="text-dark-500">
|
||||||
|
{t('admin.payments.user', 'Пользователь')}:
|
||||||
|
</span>{' '}
|
||||||
{payment.user_username ? (
|
{payment.user_username ? (
|
||||||
<span className="text-dark-200">@{payment.user_username}</span>
|
<span className="text-dark-200">@{payment.user_username}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -198,7 +228,7 @@ export default function AdminPayments() {
|
|||||||
href={payment.payment_url}
|
href={payment.payment_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn-secondary text-xs px-3 py-1.5"
|
className="btn-secondary px-3 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{t('admin.payments.openLink', 'Открыть ссылку')}
|
{t('admin.payments.openLink', 'Открыть ссылку')}
|
||||||
</a>
|
</a>
|
||||||
@@ -207,13 +237,24 @@ export default function AdminPayments() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleCheckPayment(payment)}
|
onClick={() => handleCheckPayment(payment)}
|
||||||
disabled={isChecking}
|
disabled={isChecking}
|
||||||
className="btn-primary text-xs px-3 py-1.5"
|
className="btn-primary px-3 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{isChecking ? (
|
{isChecking ? (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{t('admin.payments.checking', 'Проверка...')}
|
{t('admin.payments.checking', 'Проверка...')}
|
||||||
</span>
|
</span>
|
||||||
@@ -225,52 +266,70 @@ export default function AdminPayments() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Show result after check */}
|
{/* Show result after check */}
|
||||||
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && (
|
{checkPaymentMutation.isSuccess &&
|
||||||
<div className={`mt-3 p-2 rounded-lg text-sm ${
|
checkPaymentMutation.variables?.paymentId === payment.id && (
|
||||||
|
<div
|
||||||
|
className={`mt-3 rounded-lg p-2 text-sm ${
|
||||||
checkPaymentMutation.data?.status_changed
|
checkPaymentMutation.data?.status_changed
|
||||||
? 'bg-success-500/10 border border-success-500/30 text-success-400'
|
? 'border border-success-500/30 bg-success-500/10 text-success-400'
|
||||||
: 'bg-dark-700/30 text-dark-400'
|
: 'bg-dark-700/30 text-dark-400'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{checkPaymentMutation.data?.message}
|
{checkPaymentMutation.data?.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||||
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-8 w-8 text-dark-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-dark-400">{t('admin.payments.noPayments', 'Нет ожидающих платежей')}</div>
|
<div className="text-dark-400">
|
||||||
|
{t('admin.payments.noPayments', 'Нет ожидающих платежей')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{payments && payments.pages > 1 && (
|
{payments && payments.pages > 1 && (
|
||||||
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
|
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||||
disabled={payments.page <= 1}
|
disabled={payments.page <= 1}
|
||||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||||
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
|
payments.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('common.back', 'Назад')}
|
{t('common.back', 'Назад')}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 text-center">
|
<div className="flex-1 text-center">
|
||||||
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })}
|
{t('balance.page', '{current} / {total}', {
|
||||||
|
current: payments.page,
|
||||||
|
total: payments.pages,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
|
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
|
||||||
disabled={payments.page >= payments.pages}
|
disabled={payments.page >= payments.pages}
|
||||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||||
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : ''
|
payments.page >= payments.pages ? 'cursor-not-allowed opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('common.next', 'Далее')}
|
{t('common.next', 'Далее')}
|
||||||
@@ -279,5 +338,5 @@ export default function AdminPayments() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,104 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
promoOffersApi,
|
promoOffersApi,
|
||||||
PromoOfferTemplate,
|
PromoOfferTemplate,
|
||||||
PromoOfferTemplateUpdateRequest,
|
PromoOfferTemplateUpdateRequest,
|
||||||
|
PromoOfferBroadcastRequest,
|
||||||
PromoOfferLog,
|
PromoOfferLog,
|
||||||
TARGET_SEGMENTS,
|
TARGET_SEGMENTS,
|
||||||
TargetSegment,
|
TargetSegment,
|
||||||
OFFER_TYPE_CONFIG,
|
OFFER_TYPE_CONFIG,
|
||||||
OfferType,
|
OfferType,
|
||||||
} from '../api/promoOffers'
|
} from '../api/promoOffers';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const EditIcon = () => (
|
const EditIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const XIcon = () => (
|
const XIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SendIcon = () => (
|
const SendIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ClockIcon = () => (
|
const ClockIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UserIcon = () => (
|
const UserIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UsersIcon = () => (
|
const UsersIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const formatDateTime = (date: string | null): string => {
|
const formatDateTime = (date: string | null): string => {
|
||||||
if (!date) return '-'
|
if (!date) return '-';
|
||||||
return new Date(date).toLocaleString('ru-RU', {
|
return new Date(date).toLocaleString('ru-RU', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const getActionLabel = (action: string): string => {
|
const getActionLabel = (action: string): string => {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
@@ -79,9 +106,9 @@ const getActionLabel = (action: string): string => {
|
|||||||
claimed: 'Активировано',
|
claimed: 'Активировано',
|
||||||
consumed: 'Использовано',
|
consumed: 'Использовано',
|
||||||
disabled: 'Деактивировано',
|
disabled: 'Деактивировано',
|
||||||
}
|
};
|
||||||
return labels[action] || action
|
return labels[action] || action;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getActionColor = (action: string): string => {
|
const getActionColor = (action: string): string => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
@@ -89,37 +116,39 @@ const getActionColor = (action: string): string => {
|
|||||||
claimed: 'bg-emerald-500/20 text-emerald-400',
|
claimed: 'bg-emerald-500/20 text-emerald-400',
|
||||||
consumed: 'bg-purple-500/20 text-purple-400',
|
consumed: 'bg-purple-500/20 text-purple-400',
|
||||||
disabled: 'bg-dark-600 text-dark-400',
|
disabled: 'bg-dark-600 text-dark-400',
|
||||||
}
|
};
|
||||||
return colors[action] || 'bg-dark-600 text-dark-400'
|
return colors[action] || 'bg-dark-600 text-dark-400';
|
||||||
}
|
};
|
||||||
|
|
||||||
const getOfferTypeIcon = (offerType: string): string => {
|
const getOfferTypeIcon = (offerType: string): string => {
|
||||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'
|
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁';
|
||||||
}
|
};
|
||||||
|
|
||||||
const getOfferTypeLabel = (offerType: string): string => {
|
const getOfferTypeLabel = (offerType: string): string => {
|
||||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType
|
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Template Edit Modal
|
// Template Edit Modal
|
||||||
interface TemplateEditModalProps {
|
interface TemplateEditModalProps {
|
||||||
template: PromoOfferTemplate
|
template: PromoOfferTemplate;
|
||||||
onSave: (data: PromoOfferTemplateUpdateRequest) => void
|
onSave: (data: PromoOfferTemplateUpdateRequest) => void;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
|
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
|
||||||
const [name, setName] = useState(template.name)
|
const [name, setName] = useState(template.name);
|
||||||
const [messageText, setMessageText] = useState(template.message_text)
|
const [messageText, setMessageText] = useState(template.message_text);
|
||||||
const [buttonText, setButtonText] = useState(template.button_text)
|
const [buttonText, setButtonText] = useState(template.button_text);
|
||||||
const [validHours, setValidHours] = useState(template.valid_hours)
|
const [validHours, setValidHours] = useState(template.valid_hours);
|
||||||
const [discountPercent, setDiscountPercent] = useState(template.discount_percent)
|
const [discountPercent, setDiscountPercent] = useState(template.discount_percent);
|
||||||
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0)
|
const [activeDiscountHours, setActiveDiscountHours] = useState(
|
||||||
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0)
|
template.active_discount_hours || 0,
|
||||||
const [isActive, setIsActive] = useState(template.is_active)
|
);
|
||||||
|
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0);
|
||||||
|
const [isActive, setIsActive] = useState(template.is_active);
|
||||||
|
|
||||||
const isTestAccess = template.offer_type === 'test_access'
|
const isTestAccess = template.offer_type === 'test_access';
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const data: PromoOfferTemplateUpdateRequest = {
|
const data: PromoOfferTemplateUpdateRequest = {
|
||||||
@@ -129,84 +158,84 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
|||||||
valid_hours: validHours,
|
valid_hours: validHours,
|
||||||
discount_percent: discountPercent,
|
discount_percent: discountPercent,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
}
|
};
|
||||||
if (isTestAccess) {
|
if (isTestAccess) {
|
||||||
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined
|
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined;
|
||||||
} else {
|
} else {
|
||||||
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined
|
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined;
|
||||||
}
|
|
||||||
onSave(data)
|
|
||||||
}
|
}
|
||||||
|
onSave(data);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">Редактирование шаблона</h2>
|
||||||
Редактирование шаблона
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label>
|
<label className="mb-1 block text-sm text-dark-300">Название шаблона</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label>
|
<label className="mb-1 block text-sm text-dark-300">Текст сообщения</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={messageText}
|
value={messageText}
|
||||||
onChange={e => setMessageText(e.target.value)}
|
onChange={(e) => setMessageText(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
|
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label>
|
<label className="mb-1 block text-sm text-dark-300">Текст кнопки</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={buttonText}
|
value={buttonText}
|
||||||
onChange={e => setButtonText(e.target.value)}
|
onChange={(e) => setButtonText(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label>
|
<label className="mb-1 block text-sm text-dark-300">Срок предложения (часы)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={validHours}
|
value={validHours}
|
||||||
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
|
onChange={(e) => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
min={1}
|
min={1}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-dark-500 mt-1">Время на активацию</p>
|
<p className="mt-1 text-xs text-dark-500">Время на активацию</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isTestAccess && (
|
{!isTestAccess && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label>
|
<label className="mb-1 block text-sm text-dark-300">Скидка (%)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={discountPercent}
|
value={discountPercent}
|
||||||
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
|
onChange={(e) =>
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
@@ -216,40 +245,42 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
|||||||
|
|
||||||
{isTestAccess ? (
|
{isTestAccess ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
|
Длительность доступа (часы)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={testDurationHours}
|
value={testDurationHours}
|
||||||
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
|
onChange={(e) => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p>
|
<p className="mt-1 text-xs text-dark-500">0 = по умолчанию</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label>
|
<label className="mb-1 block text-sm text-dark-300">Действие скидки (часы)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={activeDiscountHours}
|
value={activeDiscountHours}
|
||||||
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
|
onChange={(e) => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p>
|
<p className="mt-1 text-xs text-dark-500">Сколько действует скидка после активации</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex cursor-pointer items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsActive(!isActive)}
|
onClick={() => setIsActive(!isActive)}
|
||||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||||
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
isActive ? 'left-5' : 'left-1'
|
isActive ? 'left-5' : 'left-1'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@@ -259,89 +290,89 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!name.trim() || isLoading}
|
disabled={!name.trim() || isLoading}
|
||||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Offer Modal
|
// Send Offer Modal
|
||||||
interface SendOfferModalProps {
|
interface SendOfferModalProps {
|
||||||
templates: PromoOfferTemplate[]
|
templates: PromoOfferTemplate[];
|
||||||
onSend: (templateId: number, target: string | null, userId: number | null) => void
|
onSend: (templateId: number, target: string | null, userId: number | null) => void;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
|
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null)
|
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(
|
||||||
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment')
|
templates[0]?.id || null,
|
||||||
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active')
|
);
|
||||||
const [userId, setUserId] = useState('')
|
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment');
|
||||||
|
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active');
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
|
||||||
const selectedTemplate = templates.find(t => t.id === selectedTemplateId)
|
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||||
const activeTemplates = templates.filter(t => t.is_active)
|
const activeTemplates = templates.filter((t) => t.is_active);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedTemplateId) return
|
if (!selectedTemplateId) return;
|
||||||
if (sendMode === 'user') {
|
if (sendMode === 'user') {
|
||||||
const id = parseInt(userId)
|
const id = parseInt(userId);
|
||||||
if (!id) return
|
if (!id) return;
|
||||||
onSend(selectedTemplateId, null, id)
|
onSend(selectedTemplateId, null, id);
|
||||||
} else {
|
} else {
|
||||||
onSend(selectedTemplateId, selectedTarget, null)
|
onSend(selectedTemplateId, selectedTarget, null);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isValid = () => {
|
const isValid = () => {
|
||||||
if (!selectedTemplateId) return false
|
if (!selectedTemplateId) return false;
|
||||||
if (sendMode === 'user' && !userId.trim()) return false
|
if (sendMode === 'user' && !userId.trim()) return false;
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-accent-500/20 rounded-lg">
|
<div className="rounded-lg bg-accent-500/20 p-2">
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">Отправка промопредложения</h2>
|
||||||
Отправка промопредложения
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
{/* Template Selection */}
|
{/* Template Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label>
|
<label className="mb-2 block text-sm text-dark-300">Шаблон предложения</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeTemplates.map(template => (
|
{activeTemplates.map((template) => (
|
||||||
<button
|
<button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
onClick={() => setSelectedTemplateId(template.id)}
|
onClick={() => setSelectedTemplateId(template.id)}
|
||||||
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
||||||
selectedTemplateId === template.id
|
selectedTemplateId === template.id
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
|
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
|
||||||
@@ -358,9 +389,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
|||||||
{template.valid_hours}ч на активацию
|
{template.valid_hours}ч на активацию
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedTemplateId === template.id && (
|
{selectedTemplateId === template.id && <CheckIcon />}
|
||||||
<CheckIcon />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -369,11 +398,11 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
|||||||
|
|
||||||
{/* Send Mode */}
|
{/* Send Mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label>
|
<label className="mb-2 block text-sm text-dark-300">Кому отправить</label>
|
||||||
<div className="flex gap-2 mb-3">
|
<div className="mb-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSendMode('segment')}
|
onClick={() => setSendMode('segment')}
|
||||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||||
sendMode === 'segment'
|
sendMode === 'segment'
|
||||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||||
@@ -384,7 +413,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSendMode('user')}
|
onClick={() => setSendMode('user')}
|
||||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||||
sendMode === 'user'
|
sendMode === 'user'
|
||||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||||
@@ -398,33 +427,35 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
|||||||
{sendMode === 'segment' ? (
|
{sendMode === 'segment' ? (
|
||||||
<select
|
<select
|
||||||
value={selectedTarget}
|
value={selectedTarget}
|
||||||
onChange={e => setSelectedTarget(e.target.value as TargetSegment)}
|
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
|
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
|
||||||
<option key={key} value={key}>{label}</option>
|
<option key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={userId}
|
value={userId}
|
||||||
onChange={e => setUserId(e.target.value)}
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
placeholder="Telegram ID или User ID"
|
placeholder="Telegram ID или User ID"
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
{selectedTemplate && (
|
{selectedTemplate && (
|
||||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4>
|
<h4 className="mb-2 text-sm font-medium text-dark-300">Предпросмотр</h4>
|
||||||
<div className="text-sm text-dark-200 whitespace-pre-wrap">
|
<div className="whitespace-pre-wrap text-sm text-dark-200">
|
||||||
{selectedTemplate.message_text}
|
{selectedTemplate.message_text}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg">
|
<span className="inline-block rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white">
|
||||||
{selectedTemplate.button_text}
|
{selectedTemplate.button_text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,17 +464,17 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isValid() || isLoading}
|
disabled={!isValid() || isLoading}
|
||||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
{isLoading ? 'Отправка...' : 'Отправить'}
|
{isLoading ? 'Отправка...' : 'Отправить'}
|
||||||
@@ -451,88 +482,106 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result Modal
|
// Result Modal
|
||||||
interface ResultModalProps {
|
interface ResultModalProps {
|
||||||
title: string
|
title: string;
|
||||||
message: string
|
message: string;
|
||||||
isSuccess: boolean
|
isSuccess: boolean;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center">
|
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
|
||||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
|
<div
|
||||||
|
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
||||||
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{isSuccess ? (
|
{isSuccess ? (
|
||||||
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-8 w-8 text-emerald-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-8 w-8 text-error-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
|
<h3 className="mb-2 text-lg font-semibold text-dark-100">{title}</h3>
|
||||||
<p className="text-dark-400 mb-6">{message}</p>
|
<p className="mb-6 text-dark-400">{message}</p>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
className="rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600"
|
||||||
>
|
>
|
||||||
Закрыть
|
Закрыть
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPromoOffers() {
|
export default function AdminPromoOffers() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates')
|
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates');
|
||||||
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null)
|
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null);
|
||||||
const [showSendModal, setShowSendModal] = useState(false)
|
const [showSendModal, setShowSendModal] = useState(false);
|
||||||
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null)
|
const [resultModal, setResultModal] = useState<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
isSuccess: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: templatesData, isLoading: templatesLoading } = useQuery({
|
const { data: templatesData, isLoading: templatesLoading } = useQuery({
|
||||||
queryKey: ['admin-promo-templates'],
|
queryKey: ['admin-promo-templates'],
|
||||||
queryFn: promoOffersApi.getTemplates,
|
queryFn: promoOffersApi.getTemplates,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||||
queryKey: ['admin-promo-logs'],
|
queryKey: ['admin-promo-logs'],
|
||||||
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
|
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
|
||||||
enabled: activeTab === 'logs',
|
enabled: activeTab === 'logs',
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateTemplateMutation = useMutation({
|
const updateTemplateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
|
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
|
||||||
promoOffersApi.updateTemplate(id, data),
|
promoOffersApi.updateTemplate(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] });
|
||||||
setEditingTemplate(null)
|
setEditingTemplate(null);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const broadcastMutation = useMutation({
|
const broadcastMutation = useMutation({
|
||||||
mutationFn: promoOffersApi.broadcastOffer,
|
mutationFn: promoOffersApi.broadcastOffer,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
|
||||||
setShowSendModal(false)
|
setShowSendModal(false);
|
||||||
|
|
||||||
let message = `Создано предложений: ${result.created_offers}`
|
let message = `Создано предложений: ${result.created_offers}`;
|
||||||
if (result.notifications_sent > 0 || result.notifications_failed > 0) {
|
if (result.notifications_sent > 0 || result.notifications_failed > 0) {
|
||||||
message += `\nУведомлений отправлено: ${result.notifications_sent}`
|
message += `\nУведомлений отправлено: ${result.notifications_sent}`;
|
||||||
if (result.notifications_failed > 0) {
|
if (result.notifications_failed > 0) {
|
||||||
message += ` (не доставлено: ${result.notifications_failed})`
|
message += ` (не доставлено: ${result.notifications_failed})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,22 +589,23 @@ export default function AdminPromoOffers() {
|
|||||||
title: 'Отправлено!',
|
title: 'Отправлено!',
|
||||||
message,
|
message,
|
||||||
isSuccess: true,
|
isSuccess: true,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: unknown) => {
|
||||||
|
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||||
setResultModal({
|
setResultModal({
|
||||||
title: 'Ошибка',
|
title: 'Ошибка',
|
||||||
message: error.response?.data?.detail || 'Не удалось отправить предложение',
|
message: axiosErr.response?.data?.detail || 'Не удалось отправить предложение',
|
||||||
isSuccess: false,
|
isSuccess: false,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
|
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
|
||||||
const template = templatesData?.items.find(t => t.id === templateId)
|
const template = templatesData?.items.find((t) => t.id === templateId);
|
||||||
if (!template) return
|
if (!template) return;
|
||||||
|
|
||||||
const data: any = {
|
const data: PromoOfferBroadcastRequest = {
|
||||||
notification_type: template.offer_type,
|
notification_type: template.offer_type,
|
||||||
valid_hours: template.valid_hours,
|
valid_hours: template.valid_hours,
|
||||||
discount_percent: template.discount_percent,
|
discount_percent: template.discount_percent,
|
||||||
@@ -570,29 +620,24 @@ export default function AdminPromoOffers() {
|
|||||||
send_notification: true,
|
send_notification: true,
|
||||||
message_text: template.message_text,
|
message_text: template.message_text,
|
||||||
button_text: template.button_text,
|
button_text: template.button_text,
|
||||||
}
|
...(target ? { target } : {}),
|
||||||
|
...(userId ? { telegram_id: userId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
if (target) {
|
broadcastMutation.mutate(data);
|
||||||
data.target = target
|
};
|
||||||
}
|
|
||||||
if (userId) {
|
|
||||||
data.telegram_id = userId
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastMutation.mutate(data)
|
const templates = templatesData?.items || [];
|
||||||
}
|
const logs = logsData?.items || [];
|
||||||
|
|
||||||
const templates = templatesData?.items || []
|
|
||||||
const logs = logsData?.items || []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -603,7 +648,7 @@ export default function AdminPromoOffers() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSendModal(true)}
|
onClick={() => setShowSendModal(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||||
>
|
>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
Отправить
|
Отправить
|
||||||
@@ -611,10 +656,10 @@ export default function AdminPromoOffers() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit">
|
<div className="mb-6 flex w-fit gap-1 rounded-lg bg-dark-800 p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('templates')}
|
onClick={() => setActiveTab('templates')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
activeTab === 'templates'
|
activeTab === 'templates'
|
||||||
? 'bg-dark-700 text-dark-100'
|
? 'bg-dark-700 text-dark-100'
|
||||||
: 'text-dark-400 hover:text-dark-200'
|
: 'text-dark-400 hover:text-dark-200'
|
||||||
@@ -624,10 +669,8 @@ export default function AdminPromoOffers() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('logs')}
|
onClick={() => setActiveTab('logs')}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
activeTab === 'logs'
|
activeTab === 'logs' ? 'bg-dark-700 text-dark-100' : 'text-dark-400 hover:text-dark-200'
|
||||||
? 'bg-dark-700 text-dark-100'
|
|
||||||
: 'text-dark-400 hover:text-dark-200'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Логи
|
Логи
|
||||||
@@ -639,10 +682,10 @@ export default function AdminPromoOffers() {
|
|||||||
<>
|
<>
|
||||||
{templatesLoading ? (
|
{templatesLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<p className="text-dark-400">Нет шаблонов</p>
|
<p className="text-dark-400">Нет шаблонов</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -650,21 +693,23 @@ export default function AdminPromoOffers() {
|
|||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
|
||||||
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="mb-3 flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-dark-100">{template.name}</h3>
|
<h3 className="font-medium text-dark-100">{template.name}</h3>
|
||||||
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span>
|
<span className="text-xs text-dark-500">
|
||||||
|
{getOfferTypeLabel(template.offer_type)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingTemplate(template)}
|
onClick={() => setEditingTemplate(template)}
|
||||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||||
>
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -674,7 +719,9 @@ export default function AdminPromoOffers() {
|
|||||||
{template.discount_percent > 0 && (
|
{template.discount_percent > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-dark-400">Скидка:</span>
|
<span className="text-dark-400">Скидка:</span>
|
||||||
<span className="text-accent-400 font-medium">{template.discount_percent}%</span>
|
<span className="font-medium text-accent-400">
|
||||||
|
{template.discount_percent}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -695,14 +742,14 @@ export default function AdminPromoOffers() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-dark-700">
|
<div className="mt-3 border-t border-dark-700 pt-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{template.is_active ? (
|
{template.is_active ? (
|
||||||
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
|
<span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||||
Активен
|
Активен
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||||
Неактивен
|
Неактивен
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -720,37 +767,34 @@ export default function AdminPromoOffers() {
|
|||||||
<>
|
<>
|
||||||
{logsLoading ? (
|
{logsLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<p className="text-dark-400">Нет записей</p>
|
<p className="text-dark-400">Нет записей</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{logs.map((log: PromoOfferLog) => (
|
{logs.map((log: PromoOfferLog) => (
|
||||||
<div
|
<div key={log.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
key={log.id}
|
|
||||||
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-dark-700">
|
||||||
<UserIcon />
|
<UserIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span className="font-medium text-dark-100">
|
<span className="font-medium text-dark-100">
|
||||||
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
|
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}>
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs ${getActionColor(log.action)}`}
|
||||||
|
>
|
||||||
{getActionLabel(log.action)}
|
{getActionLabel(log.action)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-dark-400">
|
<div className="text-sm text-dark-400">
|
||||||
{log.source && (
|
{log.source && <span>{getOfferTypeLabel(log.source)}</span>}
|
||||||
<span>{getOfferTypeLabel(log.source)}</span>
|
|
||||||
)}
|
|
||||||
{log.percent && log.percent > 0 && (
|
{log.percent && log.percent > 0 && (
|
||||||
<span className="ml-2 text-accent-400">{log.percent}%</span>
|
<span className="ml-2 text-accent-400">{log.percent}%</span>
|
||||||
)}
|
)}
|
||||||
@@ -799,5 +843,5 @@ export default function AdminPromoOffers() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,80 +1,113 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
|
||||||
serversApi,
|
|
||||||
ServerListItem,
|
|
||||||
ServerDetail,
|
|
||||||
ServerUpdateRequest
|
|
||||||
} from '../api/servers'
|
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const SyncIcon = () => (
|
const SyncIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const EditIcon = () => (
|
const EditIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const CheckIcon = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const XIcon = () => (
|
const XIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
const UsersIcon = () => (
|
const UsersIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Country flags (simple emoji mapping)
|
// Country flags (simple emoji mapping)
|
||||||
const getCountryFlag = (code: string | null): string => {
|
const getCountryFlag = (code: string | null): string => {
|
||||||
if (!code) return ''
|
if (!code) return '';
|
||||||
const codeMap: Record<string, string> = {
|
const codeMap: Record<string, string> = {
|
||||||
'RU': '🇷🇺', 'US': '🇺🇸', 'DE': '🇩🇪', 'NL': '🇳🇱', 'GB': '🇬🇧',
|
RU: '🇷🇺',
|
||||||
'FR': '🇫🇷', 'FI': '🇫🇮', 'SE': '🇸🇪', 'PL': '🇵🇱', 'CZ': '🇨🇿',
|
US: '🇺🇸',
|
||||||
'AT': '🇦🇹', 'CH': '🇨🇭', 'UA': '🇺🇦', 'KZ': '🇰🇿', 'JP': '🇯🇵',
|
DE: '🇩🇪',
|
||||||
'KR': '🇰🇷', 'SG': '🇸🇬', 'HK': '🇭🇰', 'CA': '🇨🇦', 'AU': '🇦🇺',
|
NL: '🇳🇱',
|
||||||
'BR': '🇧🇷', 'IN': '🇮🇳', 'TR': '🇹🇷', 'IL': '🇮🇱', 'AE': '🇦🇪',
|
GB: '🇬🇧',
|
||||||
}
|
FR: '🇫🇷',
|
||||||
return codeMap[code.toUpperCase()] || code
|
FI: '🇫🇮',
|
||||||
}
|
SE: '🇸🇪',
|
||||||
|
PL: '🇵🇱',
|
||||||
|
CZ: '🇨🇿',
|
||||||
|
AT: '🇦🇹',
|
||||||
|
CH: '🇨🇭',
|
||||||
|
UA: '🇺🇦',
|
||||||
|
KZ: '🇰🇿',
|
||||||
|
JP: '🇯🇵',
|
||||||
|
KR: '🇰🇷',
|
||||||
|
SG: '🇸🇬',
|
||||||
|
HK: '🇭🇰',
|
||||||
|
CA: '🇨🇦',
|
||||||
|
AU: '🇦🇺',
|
||||||
|
BR: '🇧🇷',
|
||||||
|
IN: '🇮🇳',
|
||||||
|
TR: '🇹🇷',
|
||||||
|
IL: '🇮🇱',
|
||||||
|
AE: '🇦🇪',
|
||||||
|
};
|
||||||
|
return codeMap[code.toUpperCase()] || code;
|
||||||
|
};
|
||||||
|
|
||||||
interface ServerModalProps {
|
interface ServerModalProps {
|
||||||
server: ServerDetail
|
server: ServerDetail;
|
||||||
onSave: (data: ServerUpdateRequest) => void
|
onSave: (data: ServerUpdateRequest) => void;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState(server.display_name)
|
const [displayName, setDisplayName] = useState(server.display_name);
|
||||||
const [description, setDescription] = useState(server.description || '')
|
const [description, setDescription] = useState(server.description || '');
|
||||||
const [countryCode, setCountryCode] = useState(server.country_code || '')
|
const [countryCode, setCountryCode] = useState(server.country_code || '');
|
||||||
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks)
|
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks);
|
||||||
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users)
|
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users);
|
||||||
const [sortOrder, setSortOrder] = useState(server.sort_order)
|
const [sortOrder, setSortOrder] = useState(server.sort_order);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const data: ServerUpdateRequest = {
|
const data: ServerUpdateRequest = {
|
||||||
@@ -84,55 +117,59 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
|||||||
price_kopeks: priceKopeks,
|
price_kopeks: priceKopeks,
|
||||||
max_users: maxUsers || undefined,
|
max_users: maxUsers || undefined,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
}
|
};
|
||||||
onSave(data)
|
onSave(data);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xl">{getCountryFlag(server.country_code)}</span>
|
<span className="text-xl">{getCountryFlag(server.country_code)}</span>
|
||||||
<h2 className="text-lg font-semibold text-dark-100">
|
<h2 className="text-lg font-semibold text-dark-100">{t('admin.servers.edit')}</h2>
|
||||||
{t('admin.servers.edit')}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
{/* Original Name (readonly) */}
|
{/* Original Name (readonly) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.originalName')}</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
<div className="px-3 py-2 bg-dark-700/50 border border-dark-600 rounded-lg text-dark-400">
|
{t('admin.servers.originalName')}
|
||||||
|
</label>
|
||||||
|
<div className="rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-dark-400">
|
||||||
{server.original_name || server.squad_uuid}
|
{server.original_name || server.squad_uuid}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Name */}
|
{/* Display Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.displayName')}</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
|
{t('admin.servers.displayName')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={e => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
placeholder={t('admin.servers.displayNamePlaceholder')}
|
placeholder={t('admin.servers.displayNamePlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.description')}</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
|
{t('admin.servers.description')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={e => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
|
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder={t('admin.servers.descriptionPlaceholder')}
|
placeholder={t('admin.servers.descriptionPlaceholder')}
|
||||||
/>
|
/>
|
||||||
@@ -140,46 +177,50 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
|||||||
|
|
||||||
{/* Country Code */}
|
{/* Country Code */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.countryCode')}</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
|
{t('admin.servers.countryCode')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={countryCode}
|
value={countryCode}
|
||||||
onChange={e => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
||||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
placeholder="RU"
|
placeholder="RU"
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
/>
|
/>
|
||||||
{countryCode && (
|
{countryCode && <span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>}
|
||||||
<span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.price')}</label>
|
<label className="mb-1 block text-sm text-dark-300">{t('admin.servers.price')}</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={priceKopeks / 100}
|
value={priceKopeks / 100}
|
||||||
onChange={e => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
onChange={(e) => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
min={0}
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
/>
|
/>
|
||||||
<span className="text-dark-400">₽</span>
|
<span className="text-dark-400">₽</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-dark-500 mt-1">{t('admin.servers.priceHint')}</p>
|
<p className="mt-1 text-xs text-dark-500">{t('admin.servers.priceHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Max Users */}
|
{/* Max Users */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.maxUsers')}</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
|
{t('admin.servers.maxUsers')}
|
||||||
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={maxUsers || ''}
|
value={maxUsers || ''}
|
||||||
onChange={e => setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)}
|
onChange={(e) =>
|
||||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)
|
||||||
|
}
|
||||||
|
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
min={0}
|
min={0}
|
||||||
placeholder={t('admin.servers.unlimited')}
|
placeholder={t('admin.servers.unlimited')}
|
||||||
/>
|
/>
|
||||||
@@ -191,34 +232,39 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
|||||||
|
|
||||||
{/* Sort Order */}
|
{/* Sort Order */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.sortOrder')}</label>
|
<label className="mb-1 block text-sm text-dark-300">
|
||||||
|
{t('admin.servers.sortOrder')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
|
onChange={(e) => setSortOrder(parseInt(e.target.value) || 0)}
|
||||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="pt-4 border-t border-dark-700">
|
<div className="border-t border-dark-700 pt-4">
|
||||||
<h4 className="text-sm font-medium text-dark-300 mb-2">{t('admin.servers.stats')}</h4>
|
<h4 className="mb-2 text-sm font-medium text-dark-300">{t('admin.servers.stats')}</h4>
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div className="p-2 bg-dark-700/50 rounded-lg">
|
<div className="rounded-lg bg-dark-700/50 p-2">
|
||||||
<span className="text-dark-400">{t('admin.servers.currentUsers')}:</span>
|
<span className="text-dark-400">{t('admin.servers.currentUsers')}:</span>
|
||||||
<span className="ml-2 text-dark-200">{server.current_users}</span>
|
<span className="ml-2 text-dark-200">{server.current_users}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 bg-dark-700/50 rounded-lg">
|
<div className="rounded-lg bg-dark-700/50 p-2">
|
||||||
<span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span>
|
<span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span>
|
||||||
<span className="ml-2 text-dark-200">{server.active_subscriptions}</span>
|
<span className="ml-2 text-dark-200">{server.active_subscriptions}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{server.tariffs_using.length > 0 && (
|
{server.tariffs_using.length > 0 && (
|
||||||
<div className="mt-2 p-2 bg-dark-700/50 rounded-lg">
|
<div className="mt-2 rounded-lg bg-dark-700/50 p-2">
|
||||||
<span className="text-dark-400 text-sm">{t('admin.servers.usedByTariffs')}:</span>
|
<span className="text-sm text-dark-400">{t('admin.servers.usedByTariffs')}:</span>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{server.tariffs_using.map(tariff => (
|
{server.tariffs_using.map((tariff) => (
|
||||||
<span key={tariff} className="px-2 py-0.5 bg-dark-600 text-dark-300 text-xs rounded">
|
<span
|
||||||
|
key={tariff}
|
||||||
|
className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-300"
|
||||||
|
>
|
||||||
{tariff}
|
{tariff}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -229,103 +275,103 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||||
>
|
>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!displayName || isLoading}
|
disabled={!displayName || isLoading}
|
||||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? t('common.loading') : t('common.save')}
|
{isLoading ? t('common.loading') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminServers() {
|
export default function AdminServers() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null)
|
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null);
|
||||||
const [loadingServerId, setLoadingServerId] = useState<number | null>(null)
|
const [loadingServerId, setLoadingServerId] = useState<number | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: serversData, isLoading } = useQuery({
|
const { data: serversData, isLoading } = useQuery({
|
||||||
queryKey: ['admin-servers'],
|
queryKey: ['admin-servers'],
|
||||||
queryFn: () => serversApi.getServers(true),
|
queryFn: () => serversApi.getServers(true),
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) =>
|
mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) =>
|
||||||
serversApi.updateServer(id, data),
|
serversApi.updateServer(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||||
setEditingServer(null)
|
setEditingServer(null);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: serversApi.toggleServer,
|
mutationFn: serversApi.toggleServer,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const toggleTrialMutation = useMutation({
|
const toggleTrialMutation = useMutation({
|
||||||
mutationFn: serversApi.toggleTrial,
|
mutationFn: serversApi.toggleTrial,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: serversApi.syncServers,
|
mutationFn: serversApi.syncServers,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||||
alert(result.message)
|
alert(result.message);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const handleEdit = async (serverId: number) => {
|
const handleEdit = async (serverId: number) => {
|
||||||
setLoadingServerId(serverId)
|
setLoadingServerId(serverId);
|
||||||
setLoadError(null)
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const detail = await serversApi.getServer(serverId)
|
const detail = await serversApi.getServer(serverId);
|
||||||
setEditingServer(detail)
|
setEditingServer(detail);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Failed to load server:', error)
|
console.error('Failed to load server:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError')
|
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError');
|
||||||
setLoadError(errorMessage)
|
setLoadError(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerId(null)
|
setLoadingServerId(null);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = (data: ServerUpdateRequest) => {
|
const handleSave = (data: ServerUpdateRequest) => {
|
||||||
if (editingServer) {
|
if (editingServer) {
|
||||||
updateMutation.mutate({ id: editingServer.id, data })
|
updateMutation.mutate({ id: editingServer.id, data });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const servers = serversData?.servers || []
|
const servers = serversData?.servers || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -337,7 +383,7 @@ export default function AdminServers() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => syncMutation.mutate()}
|
onClick={() => syncMutation.mutate()}
|
||||||
disabled={syncMutation.isPending}
|
disabled={syncMutation.isPending}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<SyncIcon />
|
<SyncIcon />
|
||||||
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
|
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
|
||||||
@@ -347,10 +393,10 @@ export default function AdminServers() {
|
|||||||
{/* Servers List */}
|
{/* Servers List */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
) : servers.length === 0 ? (
|
) : servers.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="py-12 text-center">
|
||||||
<p className="text-dark-400">{t('admin.servers.noServers')}</p>
|
<p className="text-dark-400">{t('admin.servers.noServers')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => syncMutation.mutate()}
|
onClick={() => syncMutation.mutate()}
|
||||||
@@ -364,27 +410,27 @@ export default function AdminServers() {
|
|||||||
{servers.map((server: ServerListItem) => (
|
{servers.map((server: ServerListItem) => (
|
||||||
<div
|
<div
|
||||||
key={server.id}
|
key={server.id}
|
||||||
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
|
||||||
server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span className="text-lg">{getCountryFlag(server.country_code)}</span>
|
<span className="text-lg">{getCountryFlag(server.country_code)}</span>
|
||||||
<h3 className="font-medium text-dark-100 truncate">{server.display_name}</h3>
|
<h3 className="truncate font-medium text-dark-100">{server.display_name}</h3>
|
||||||
{server.is_trial_eligible && (
|
{server.is_trial_eligible && (
|
||||||
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
|
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||||
{t('admin.servers.trial')}
|
{t('admin.servers.trial')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!server.is_available && (
|
{!server.is_available && (
|
||||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||||
{t('admin.servers.unavailable')}
|
{t('admin.servers.unavailable')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{server.is_full && (
|
{server.is_full && (
|
||||||
<span className="px-2 py-0.5 text-xs bg-warning-500/20 text-warning-400 rounded">
|
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||||
{t('admin.servers.full')}
|
{t('admin.servers.full')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -396,7 +442,9 @@ export default function AdminServers() {
|
|||||||
{server.max_users ? ` / ${server.max_users}` : ''}
|
{server.max_users ? ` / ${server.max_users}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span>{server.price_rubles} ₽</span>
|
<span>{server.price_rubles} ₽</span>
|
||||||
<span className="text-dark-500 text-xs font-mono truncate max-w-[200px]">{server.squad_uuid}</span>
|
<span className="max-w-[200px] truncate font-mono text-xs text-dark-500">
|
||||||
|
{server.squad_uuid}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -404,12 +452,14 @@ export default function AdminServers() {
|
|||||||
{/* Toggle Available */}
|
{/* Toggle Available */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleMutation.mutate(server.id)}
|
onClick={() => toggleMutation.mutate(server.id)}
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
className={`rounded-lg p-2 transition-colors ${
|
||||||
server.is_available
|
server.is_available
|
||||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||||
}`}
|
}`}
|
||||||
title={server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')}
|
title={
|
||||||
|
server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{server.is_available ? <CheckIcon /> : <XIcon />}
|
{server.is_available ? <CheckIcon /> : <XIcon />}
|
||||||
</button>
|
</button>
|
||||||
@@ -417,7 +467,7 @@ export default function AdminServers() {
|
|||||||
{/* Toggle Trial */}
|
{/* Toggle Trial */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleTrialMutation.mutate(server.id)}
|
onClick={() => toggleTrialMutation.mutate(server.id)}
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
className={`rounded-lg p-2 transition-colors ${
|
||||||
server.is_trial_eligible
|
server.is_trial_eligible
|
||||||
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
|
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
|
||||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||||
@@ -431,11 +481,11 @@ export default function AdminServers() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(server.id)}
|
onClick={() => handleEdit(server.id)}
|
||||||
disabled={loadingServerId === server.id}
|
disabled={loadingServerId === server.id}
|
||||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors disabled:opacity-50"
|
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:opacity-50"
|
||||||
title={t('admin.servers.edit')}
|
title={t('admin.servers.edit')}
|
||||||
>
|
>
|
||||||
{loadingServerId === server.id ? (
|
{loadingServerId === server.id ? (
|
||||||
<div className="w-4 h-4 border-2 border-dark-300 border-t-transparent rounded-full animate-spin" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-300 border-t-transparent" />
|
||||||
) : (
|
) : (
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
)}
|
)}
|
||||||
@@ -449,12 +499,9 @@ export default function AdminServers() {
|
|||||||
|
|
||||||
{/* Error Toast */}
|
{/* Error Toast */}
|
||||||
{loadError && (
|
{loadError && (
|
||||||
<div className="fixed bottom-4 right-4 bg-error-500/90 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 z-50">
|
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-error-500/90 px-4 py-3 text-white shadow-lg">
|
||||||
<span>{loadError}</span>
|
<span>{loadError}</span>
|
||||||
<button
|
<button onClick={() => setLoadError(null)} className="rounded p-1 hover:bg-white/20">
|
||||||
onClick={() => setLoadError(null)}
|
|
||||||
className="p-1 hover:bg-white/20 rounded"
|
|
||||||
>
|
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,5 +517,5 @@ export default function AdminServers() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +1,120 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next';
|
||||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings'
|
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||||
import { themeColorsApi } from '../api/themeColors'
|
import { themeColorsApi } from '../api/themeColors';
|
||||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings'
|
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||||
|
import { MenuIcon, MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
||||||
|
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||||
|
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||||
|
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||||
|
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||||
|
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||||
|
import { SettingsSidebar } from '../components/admin/SettingsSidebar';
|
||||||
import {
|
import {
|
||||||
MenuIcon,
|
SettingsSearch,
|
||||||
MENU_SECTIONS,
|
SettingsSearchMobile,
|
||||||
MenuItem,
|
SettingsSearchResults,
|
||||||
formatSettingKey
|
} from '../components/admin/SettingsSearch';
|
||||||
} from '../components/admin'
|
|
||||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab'
|
|
||||||
import { BrandingTab } from '../components/admin/BrandingTab'
|
|
||||||
import { ThemeTab } from '../components/admin/ThemeTab'
|
|
||||||
import { FavoritesTab } from '../components/admin/FavoritesTab'
|
|
||||||
import { SettingsTab } from '../components/admin/SettingsTab'
|
|
||||||
import { SettingsSidebar } from '../components/admin/SettingsSidebar'
|
|
||||||
import { SettingsSearch, SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch'
|
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [activeSection, setActiveSection] = useState('branding')
|
const [activeSection, setActiveSection] = useState('branding');
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
// Favorites hook
|
// Favorites hook
|
||||||
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings()
|
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings();
|
||||||
|
|
||||||
// Scroll to top on mount and section change (fix for mobile webviews)
|
// Scroll to top on mount and section change (fix for mobile webviews)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use requestAnimationFrame for smoother scroll on mobile
|
// Use requestAnimationFrame for smoother scroll on mobile
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||||
document.documentElement.scrollTop = 0
|
document.documentElement.scrollTop = 0;
|
||||||
document.body.scrollTop = 0
|
document.body.scrollTop = 0;
|
||||||
})
|
});
|
||||||
}, [activeSection])
|
}, [activeSection]);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: themeColors } = useQuery({
|
const { data: themeColors } = useQuery({
|
||||||
queryKey: ['theme-colors'],
|
queryKey: ['theme-colors'],
|
||||||
queryFn: themeColorsApi.getColors,
|
queryFn: themeColorsApi.getColors,
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: allSettings } = useQuery({
|
const { data: allSettings } = useQuery({
|
||||||
queryKey: ['admin-settings'],
|
queryKey: ['admin-settings'],
|
||||||
queryFn: () => adminSettingsApi.getSettings(),
|
queryFn: () => adminSettingsApi.getSettings(),
|
||||||
})
|
});
|
||||||
|
|
||||||
// Get current menu item configuration
|
// Get current menu item configuration
|
||||||
const currentMenuItem = useMemo(() => {
|
const currentMenuItem = useMemo(() => {
|
||||||
for (const section of MENU_SECTIONS) {
|
for (const section of MENU_SECTIONS) {
|
||||||
const item = section.items.find((i: MenuItem) => i.id === activeSection)
|
const item = section.items.find((i: MenuItem) => i.id === activeSection);
|
||||||
if (item) return item
|
if (item) return item;
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}, [activeSection])
|
}, [activeSection]);
|
||||||
|
|
||||||
// Get categories for current section
|
// Get categories for current section
|
||||||
const currentCategories = useMemo(() => {
|
const currentCategories = useMemo(() => {
|
||||||
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return []
|
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
|
||||||
|
|
||||||
const categoryMap = new Map<string, SettingDefinition[]>()
|
const categoryMap = new Map<string, SettingDefinition[]>();
|
||||||
|
|
||||||
for (const setting of allSettings) {
|
for (const setting of allSettings) {
|
||||||
if (currentMenuItem.categories.includes(setting.category.key)) {
|
if (currentMenuItem.categories.includes(setting.category.key)) {
|
||||||
if (!categoryMap.has(setting.category.key)) {
|
if (!categoryMap.has(setting.category.key)) {
|
||||||
categoryMap.set(setting.category.key, [])
|
categoryMap.set(setting.category.key, []);
|
||||||
}
|
}
|
||||||
categoryMap.get(setting.category.key)!.push(setting)
|
categoryMap.get(setting.category.key)!.push(setting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(categoryMap.entries()).map(([key, settings]) => ({
|
return Array.from(categoryMap.entries()).map(([key, settings]) => ({
|
||||||
key,
|
key,
|
||||||
label: t(`admin.settings.categories.${key}`, key),
|
label: t(`admin.settings.categories.${key}`, key),
|
||||||
settings
|
settings,
|
||||||
}))
|
}));
|
||||||
}, [currentMenuItem, allSettings, t])
|
}, [currentMenuItem, allSettings, t]);
|
||||||
|
|
||||||
// Filter settings for search - GLOBAL search across all settings
|
// Filter settings for search - GLOBAL search across all settings
|
||||||
const filteredSettings = useMemo(() => {
|
const filteredSettings = useMemo(() => {
|
||||||
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return []
|
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return [];
|
||||||
|
|
||||||
const q = searchQuery.toLowerCase().trim()
|
const q = searchQuery.toLowerCase().trim();
|
||||||
if (!q) return []
|
if (!q) return [];
|
||||||
|
|
||||||
return allSettings.filter((s: SettingDefinition) => {
|
return allSettings.filter((s: SettingDefinition) => {
|
||||||
// Search by key
|
// Search by key
|
||||||
if (s.key.toLowerCase().includes(q)) return true
|
if (s.key.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by original name
|
// Search by original name
|
||||||
if (s.name?.toLowerCase().includes(q)) return true
|
if (s.name?.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by translated name
|
// Search by translated name
|
||||||
const formattedKey = formatSettingKey(s.name || s.key)
|
const formattedKey = formatSettingKey(s.name || s.key);
|
||||||
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
|
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
if (translatedName.toLowerCase().includes(q)) return true
|
if (translatedName.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by description
|
// Search by description
|
||||||
if (s.hint?.description?.toLowerCase().includes(q)) return true
|
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by category
|
// Search by category
|
||||||
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key)
|
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||||
if (categoryLabel.toLowerCase().includes(q)) return true
|
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
}, [allSettings, searchQuery, t])
|
}, [allSettings, searchQuery, t]);
|
||||||
|
|
||||||
// Favorite settings
|
// Favorite settings
|
||||||
const favoriteSettings = useMemo(() => {
|
const favoriteSettings = useMemo(() => {
|
||||||
if (!allSettings || !Array.isArray(allSettings)) return []
|
if (!allSettings || !Array.isArray(allSettings)) return [];
|
||||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key))
|
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
||||||
}, [allSettings, favorites])
|
}, [allSettings, favorites]);
|
||||||
|
|
||||||
// Render content based on active section
|
// Render content based on active section
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
@@ -129,16 +128,16 @@ export default function AdminSettings() {
|
|||||||
isFavorite={isFavorite}
|
isFavorite={isFavorite}
|
||||||
toggleFavorite={toggleFavorite}
|
toggleFavorite={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
case 'analytics':
|
case 'analytics':
|
||||||
return <AnalyticsTab />
|
return <AnalyticsTab />;
|
||||||
case 'branding':
|
case 'branding':
|
||||||
return <BrandingTab accentColor={themeColors?.accent} />
|
return <BrandingTab accentColor={themeColors?.accent} />;
|
||||||
case 'theme':
|
case 'theme':
|
||||||
return <ThemeTab />
|
return <ThemeTab />;
|
||||||
case 'favorites':
|
case 'favorites':
|
||||||
return (
|
return (
|
||||||
<FavoritesTab
|
<FavoritesTab
|
||||||
@@ -146,9 +145,19 @@ export default function AdminSettings() {
|
|||||||
isFavorite={isFavorite}
|
isFavorite={isFavorite}
|
||||||
toggleFavorite={toggleFavorite}
|
toggleFavorite={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
default:
|
default:
|
||||||
if (['payments', 'subscriptions', 'interface', 'notifications', 'database', 'system', 'users'].includes(activeSection)) {
|
if (
|
||||||
|
[
|
||||||
|
'payments',
|
||||||
|
'subscriptions',
|
||||||
|
'interface',
|
||||||
|
'notifications',
|
||||||
|
'database',
|
||||||
|
'system',
|
||||||
|
'users',
|
||||||
|
].includes(activeSection)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<SettingsTab
|
<SettingsTab
|
||||||
categories={currentCategories}
|
categories={currentCategories}
|
||||||
@@ -157,18 +166,18 @@ export default function AdminSettings() {
|
|||||||
isFavorite={isFavorite}
|
isFavorite={isFavorite}
|
||||||
toggleFavorite={toggleFavorite}
|
toggleFavorite={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen pt-safe">
|
<div className="pt-safe flex min-h-screen">
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -183,18 +192,18 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 min-w-0">
|
<main className="min-w-0 flex-1">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 z-30 bg-dark-900/95 backdrop-blur-xl border-b border-dark-700/50 p-3 sm:p-4">
|
<div className="sticky top-0 z-30 border-b border-dark-700/50 bg-dark-900/95 p-3 backdrop-blur-xl sm:p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(true)}
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
|
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-dark-100 truncate">
|
<h2 className="truncate text-lg font-semibold text-dark-100 sm:text-xl">
|
||||||
{t(`admin.settings.${activeSection}`)}
|
{t(`admin.settings.${activeSection}`)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -207,22 +216,14 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSearchMobile
|
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
||||||
searchQuery={searchQuery}
|
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingsSearchResults
|
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||||
searchQuery={searchQuery}
|
|
||||||
resultsCount={filteredSettings.length}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-3 sm:p-4 lg:p-6">
|
<div className="p-3 sm:p-4 lg:p-6">{renderContent()}</div>
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user