Merge pull request #179 from BEDOLAGA-DEV/dev

chore: sync dev → main (v1.9.0)
This commit is contained in:
Egor
2026-02-07 07:22:56 +03:00
committed by GitHub
31 changed files with 2239 additions and 796 deletions

133
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cabinet-frontend",
"version": "1.8.0",
"version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cabinet-frontend",
"version": "1.8.0",
"version": "1.9.0",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -40,6 +40,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-router": "^7.13.0",
"react-twemoji": "^0.7.2",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
},
@@ -49,6 +50,7 @@
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/react-twemoji": "^0.4.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -110,7 +112,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -388,7 +389,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -2729,6 +2729,24 @@
"deprecated": "This package is not supported anymore. Use @tma.js/types instead",
"license": "MIT"
},
"node_modules/@twemoji/api": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/@twemoji/api/-/api-17.0.2.tgz",
"integrity": "sha512-5xIdBcFLpRW4goXNdNNz497TzMHF2a76yElEaqISxVPj+KpJB82KuXHczfjhhTCsPaP1lAhM3qPtvAhMG7e71A==",
"license": "MIT AND CC-BY-4.0",
"dependencies": {
"@twemoji/parser": "17.0.1",
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/@twemoji/parser": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/@twemoji/parser/-/parser-17.0.1.tgz",
"integrity": "sha512-Uiq5sq1Wx91Y6C0DczMlu4Gj4nC/0z8UIhT7S53hi/JATaS1oLM5jXygjot7STtyVoIagfnpsBCWaNy3ayfyFw==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2804,7 +2822,6 @@
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2815,7 +2832,6 @@
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2826,11 +2842,20 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-twemoji": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@types/react-twemoji/-/react-twemoji-0.4.3.tgz",
"integrity": "sha512-VzWkjmO8yJ31JSWgQhe0LCLU3FxcurTOklECm9p8v1xI/WfUBOGbSrqPjQPL46LE4oOqfrYIQ+0LM/8I6A8EDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2883,7 +2908,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -3135,7 +3159,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3396,7 +3419,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3734,6 +3756,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -3924,7 +3955,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4327,6 +4357,29 @@
}
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4458,6 +4511,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -4568,7 +4627,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -4718,7 +4776,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -4747,8 +4804,7 @@
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
@@ -4797,6 +4853,18 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"license": "MIT",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5314,7 +5382,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5473,7 +5540,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -5605,7 +5671,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5615,7 +5680,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5751,6 +5815,23 @@
}
}
},
"node_modules/react-twemoji": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/react-twemoji/-/react-twemoji-0.7.2.tgz",
"integrity": "sha512-sNuzC0YIUtwWzoWq4cA6YCozIRu/9lDO8Anl2hlU5lQainZ2ZH6sB7wOv2wyTY462QSXqq9Y8T7rHgEjCfpVvg==",
"license": "MIT",
"dependencies": {
"@twemoji/api": "17.0.2",
"dequal": "^2.0.3"
},
"engines": {
"node": ">=5.0"
},
"peerDependencies": {
"react": ">=19.0.0",
"react-dom": ">=19.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -6233,7 +6314,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6299,7 +6379,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6339,6 +6418,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -6458,7 +6546,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -6552,7 +6639,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6686,7 +6772,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,7 +1,7 @@
{
"name": "cabinet-frontend",
"private": true,
"version": "1.8.0",
"version": "1.9.0",
"description": "Remnawave Bedolaga Web App",
"license": "AGPL-3.0",
"type": "module",
@@ -48,6 +48,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-router": "^7.13.0",
"react-twemoji": "^0.7.2",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
},
@@ -57,6 +58,7 @@
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@types/react-twemoji": "^0.4.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",

View File

@@ -18,6 +18,7 @@ import TelegramRedirect from './pages/TelegramRedirect';
import DeepLinkRedirect from './pages/DeepLinkRedirect';
import VerifyEmail from './pages/VerifyEmail';
import ResetPassword from './pages/ResetPassword';
import OAuthCallback from './pages/OAuthCallback';
// User pages - lazy load
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -147,6 +148,7 @@ function App() {
<Route path="/tg" element={<TelegramRedirect />} />
<Route path="/connect" element={<DeepLinkRedirect />} />
<Route path="/add" element={<DeepLinkRedirect />} />
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/reset-password" element={<ResetPassword />} />

View File

@@ -6,6 +6,7 @@ import {
onBackButtonClick,
offBackButtonClick,
} from '@telegram-apps/sdk-react';
import Twemoji from 'react-twemoji';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import { PlatformProvider } from './platform/PlatformProvider';
@@ -73,7 +74,9 @@ export function AppWithNavigator() {
<TooltipProvider>
<ToastProvider>
<WebSocketProvider>
<App />
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
<App />
</Twemoji>
</WebSocketProvider>
</ToastProvider>
</TooltipProvider>

View File

@@ -101,6 +101,7 @@ export const adminApi = {
per_page?: number;
status?: string;
priority?: string;
user_id?: number;
} = {},
): Promise<AdminTicketListResponse> => {
const response = await apiClient.get('/cabinet/admin/tickets', { params });

View File

@@ -101,6 +101,8 @@ export interface UserDetailResponse {
used_promocodes: number;
has_had_paid_subscription: boolean;
lifetime_used_traffic_bytes: number;
campaign_name: string | null;
campaign_id: number | null;
restriction_topup: boolean;
restriction_subscription: boolean;
restriction_reason: string | null;
@@ -111,6 +113,36 @@ export interface UserDetailResponse {
remnawave_uuid: string | null;
}
export interface UserPanelInfo {
found: boolean;
trojan_password: string | null;
vless_uuid: string | null;
ss_password: string | null;
subscription_url: string | null;
happ_link: string | null;
used_traffic_bytes: number;
lifetime_used_traffic_bytes: number;
traffic_limit_bytes: number;
first_connected_at: string | null;
online_at: string | null;
last_connected_node_uuid: string | null;
last_connected_node_name: string | null;
}
export interface UserNodeUsageItem {
node_uuid: string;
node_name: string;
country_code: string;
total_bytes: number;
daily_bytes: number[];
}
export interface UserNodeUsageResponse {
items: UserNodeUsageItem[];
categories: string[];
period_days: number;
}
export interface UsersStatsResponse {
total_users: number;
active_users: number;
@@ -415,7 +447,7 @@ export const adminUsersApi = {
return response.data;
},
// Delete user
// Delete user (soft delete, does NOT remove from Remnawave)
deleteUser: async (
userId: number,
softDelete = true,
@@ -427,6 +459,20 @@ export const adminUsersApi = {
return response.data;
},
// Full delete user (removes from bot DB + Remnawave panel)
fullDeleteUser: async (
userId: number,
): Promise<{
success: boolean;
message: string;
deleted_from_bot: boolean;
deleted_from_panel: boolean;
panel_error: string | null;
}> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/full`);
return response.data;
},
// Get referrals
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
@@ -494,4 +540,16 @@ export const adminUsersApi = {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
return response.data;
},
// Get panel info
getPanelInfo: async (userId: number): Promise<UserPanelInfo> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`);
return response.data;
},
// Get node usage (always 30 days with daily breakdown)
getNodeUsage: async (userId: number): Promise<UserNodeUsageResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
return response.data;
},
};

View File

@@ -1,5 +1,5 @@
import apiClient from './client';
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User } from '../types';
export const authApi = {
// Telegram WebApp authentication
@@ -124,4 +124,31 @@ export const authApi = {
});
return response.data;
},
// OAuth: get enabled providers
getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => {
const response = await apiClient.get<{ providers: OAuthProvider[] }>(
'/cabinet/auth/oauth/providers',
);
return response.data;
},
// OAuth: get authorization URL
getOAuthAuthorizeUrl: async (
provider: string,
): Promise<{ authorize_url: string; state: string }> => {
const response = await apiClient.get<{ authorize_url: string; state: string }>(
`/cabinet/auth/oauth/${provider}/authorize`,
);
return response.data;
},
// OAuth: callback (exchange code for tokens)
oauthCallback: async (provider: string, code: string, state: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>(
`/cabinet/auth/oauth/${provider}/callback`,
{ code, state },
);
return response.data;
},
};

View File

@@ -74,6 +74,7 @@ const AUTH_ENDPOINTS = [
'/cabinet/auth/refresh',
'/cabinet/auth/password/forgot',
'/cabinet/auth/password/reset',
'/cabinet/auth/oauth/',
];
function isAuthEndpoint(url: string | undefined): boolean {

View File

@@ -0,0 +1,74 @@
interface OAuthProviderIconProps {
provider: string;
className?: string;
}
export default function OAuthProviderIcon({
provider,
className = 'h-5 w-5',
}: OAuthProviderIconProps) {
switch (provider) {
case 'google':
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
case 'yandex':
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"
fill="#FC3F1D"
/>
<path
d="M13.63 7.56c-.7 0-1.2.18-1.6.52-.4.34-.6.85-.6 1.5 0 .5.13.92.38 1.26.25.34.68.73 1.29 1.16l.55.4-2.45 5.1H9.6l2.12-4.32c-.83-.57-1.43-1.12-1.8-1.66-.37-.54-.56-1.2-.56-1.96 0-1.03.35-1.85 1.04-2.47.7-.62 1.65-.93 2.87-.93H15.4V17.5h-1.77V7.56z"
fill="#fff"
/>
</svg>
);
case 'discord':
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028 14.09 14.09 0 001.226-1.994.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
fill="#5865F2"
/>
</svg>
);
case 'vk':
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z"
fill="#0077FF"
/>
<path
d="M12.76 16.42h.86s.26-.03.39-.17c.12-.13.12-.37.12-.37s-.02-1.14.51-1.31c.53-.16 1.2 1.08 1.92 1.56.54.36.96.28.96.28l1.93-.03s1.01-.06.53-.84c-.04-.06-.28-.58-1.45-1.64-1.22-1.1-1.06-.93.41-2.84.9-1.16 1.26-1.87 1.15-2.17-.1-.29-.76-.21-.76-.21l-2.17.01s-.16-.02-.28.05c-.12.07-.19.23-.19.23s-.35.93-.81 1.72c-.98 1.67-1.37 1.76-1.53 1.65-.37-.24-.28-1-.28-1.53 0-1.66.25-2.35-.49-2.53-.25-.06-.43-.1-1.06-.1-.81-.01-1.5 0-1.89.19-.26.13-.46.42-.34.44.15.02.5.09.68.34.24.32.23 1.04.23 1.04s.14 1.96-.32 2.2c-.31.17-.74-.17-1.66-1.7-.47-.78-.83-1.65-.83-1.65s-.07-.17-.19-.26c-.15-.11-.36-.14-.36-.14l-2.06.01s-.31.01-.42.14c-.1.12-.01.36-.01.36s1.63 3.81 3.47 5.74c1.69 1.77 3.61 1.65 3.61 1.65z"
fill="#fff"
/>
</svg>
);
default:
return null;
}
}

View File

@@ -0,0 +1,170 @@
interface PaymentMethodIconProps {
method: string;
className?: string;
}
export default function PaymentMethodIcon({
method,
className = 'h-8 w-8',
}: PaymentMethodIconProps) {
switch (method) {
case 'telegram_stars':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#229ED9" />
<path
d="M20 8l3.09 6.26L30 15.27l-5 4.87 1.18 6.88L20 23.77l-6.18 3.25L15 20.14l-5-4.87 6.91-1.01L20 8z"
fill="#FFD700"
/>
</svg>
);
case 'cryptobot':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#0088CC" />
<path
d="M20 7c-7.18 0-13 5.82-13 13s5.82 13 13 13 13-5.82 13-13S27.18 7 20 7zm1.5 18.75v2.5h-3v-2.4c-2.1-.3-3.8-1.2-4.6-2.1l1.8-2.4c.9.8 2.1 1.5 3.3 1.5 1.1 0 1.8-.5 1.8-1.3 0-.9-.8-1.3-2.5-1.9-2.3-.8-4.2-1.9-4.2-4.4 0-2.1 1.5-3.7 4.2-4.1v-2.4h3v2.3c1.6.2 2.9.9 3.8 1.7l-1.7 2.3c-.7-.6-1.7-1.1-2.8-1.1-1 0-1.6.4-1.6 1.2 0 .8.8 1.2 2.6 1.9 2.5.9 4.1 2 4.1 4.4 0 2.2-1.6 3.9-4.2 4.2z"
fill="#fff"
/>
</svg>
);
case 'yookassa':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#0070F0" />
<g fill="#fff">
<rect x="11" y="11" width="4" height="18" rx="2" />
<circle cx="23" cy="20" r="7" fill="none" stroke="#fff" strokeWidth="3.5" />
</g>
</svg>
);
case 'heleket':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#1A1A2E" />
<path
d="M20 8l2.47 5.01.55 1.12 1.23.18L30 15.27l-4 3.9-.89.87.21 1.22L26.18 27 20.9 24.22l-1.1-.58-1.1.58L13.62 27l.86-5.74.21-1.22-.89-.87-4-3.9 5.75-.96 1.23-.18.55-1.12L20 8z"
fill="#00E68A"
/>
</svg>
);
case 'mulenpay':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#FF4D4D" />
<path
d="M10 14h20c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2H10c-1.1 0-2-.9-2-2v-8c0-1.1.9-2 2-2zm0 3v6h20v-6H10zm2 2h4v2h-4v-2z"
fill="#fff"
fillRule="evenodd"
/>
</svg>
);
case 'pal24':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#00B341" />
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
<text x="20" y="26" textAnchor="middle" fontSize="16">
P24
</text>
</g>
</svg>
);
case 'platega':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#6C5CE7" />
<path
d="M14 12h8c3.31 0 6 2.69 6 6s-2.69 6-6 6h-4v4h-4V12zm4 8h4c1.1 0 2-.9 2-2s-.9-2-2-2h-4v4z"
fill="#fff"
/>
</svg>
);
case 'wata':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#2D7FF9" />
<path
d="M20 10c-.55 0-1 .35-1.27.8L12 24c-.4.7.1 1.5.9 1.5h14.2c.8 0 1.3-.8.9-1.5l-6.73-13.2c-.27-.45-.72-.8-1.27-.8z"
fill="#fff"
opacity=".9"
/>
<path
d="M20 30c3.87 0 7-2.24 7-5 0-2.76-7-10-7-10s-7 7.24-7 10c0 2.76 3.13 5 7 5z"
fill="#fff"
/>
</svg>
);
case 'freekassa':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#FF6600" />
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
<text x="20" y="26" textAnchor="middle" fontSize="16">
FK
</text>
</g>
</svg>
);
case 'cloudpayments':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#004EBD" />
<path
d="M28.5 22c.83 0 1.5.67 1.5 1.5S29.33 25 28.5 25h-17c-1.93 0-3.5-1.57-3.5-3.5 0-1.72 1.24-3.15 2.88-3.45C11.15 15.17 13.86 13 17 13c2.48 0 4.65 1.3 5.89 3.25C23.56 15.48 24.7 15 26 15c2.76 0 5 2.24 5 5 0 .7-.15 1.37-.42 1.97-.02.01-.04.03-.08.03h-2z"
fill="#fff"
/>
</svg>
);
case 'tribute':
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#7B61FF" />
<path
d="M20 10l2.94 5.96L29 17.02l-4.5 4.39L25.56 28 20 25.14 14.44 28l1.06-6.59L11 17.02l6.06-.96L20 10z"
fill="#fff"
/>
</svg>
);
case 'kassa_ai':
return (
<svg className={className} viewBox="0 0 40 40">
<defs>
<linearGradient id="kassaAiGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#6366F1" />
<stop offset="100%" stopColor="#8B5CF6" />
</linearGradient>
</defs>
<circle cx="20" cy="20" r="20" fill="url(#kassaAiGrad)" />
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
<text x="20" y="26" textAnchor="middle" fontSize="15">
AI
</text>
</g>
</svg>
);
default:
return (
<svg className={className} viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="#4B5563" />
<path
d="M10 14h20c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2H10c-1.1 0-2-.9-2-2v-8c0-1.1.9-2 2-2zm0 3v6h20v-6H10z"
fill="#fff"
opacity=".7"
/>
</svg>
);
}
}

View File

@@ -85,8 +85,8 @@ export function ThemeTab() {
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
savedColorsRef.current = colors;
}
savedColorsRef.current = colors;
}
}, [serverColors]);
@@ -395,34 +395,32 @@ export function ThemeTab() {
/>
</div>
</div>
{/* Save / Cancel / Reset buttons */}
<div className="flex flex-wrap items-center gap-3">
{hasUnsavedChanges && (
<>
<button
onClick={() => updateColorsMutation.mutate(draftColors)}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateColorsMutation.isPending
? t('common.saving', t('common.save'))
: t('common.save')}
</button>
<button
onClick={handleCancel}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</>
)}
</div>
</div>
)}
</div>
{/* Save / Cancel — always visible when there are unsaved changes */}
{hasUnsavedChanges && (
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => updateColorsMutation.mutate(draftColors)}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateColorsMutation.isPending
? t('common.saving', t('common.save'))
: t('common.save')}
</button>
<button
onClick={handleCancel}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</div>
)}
{/* Reset all colors */}
<div className="flex justify-end">
<button

View File

@@ -121,6 +121,22 @@ const ShieldIcon = ({ className }: { className?: string }) => (
</svg>
);
const InfoIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
);
const LogoutIcon = ({ className }: { className?: string }) => (
<svg
className={className}
@@ -236,6 +252,7 @@ export function AppShell({ children }: AppShellProps) {
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
];

View File

@@ -187,7 +187,13 @@
"forgotPasswordHint": "Enter your email to receive a reset link",
"loginSuccess": "Login successful",
"passwordResetSent": "Password reset link sent to your email",
"sendResetLink": "Send reset link"
"sendResetLink": "Send reset link",
"continueWithGoogle": "Continue with Google",
"continueWithYandex": "Continue with Yandex",
"continueWithDiscord": "Continue with Discord",
"continueWithVk": "Continue with VK",
"oauthError": "Authorization was denied or failed",
"oauthExpired": "OAuth session expired. Please try again."
},
"emailVerification": {
"title": "Email Verification",
@@ -1093,7 +1099,12 @@
"emailContent": "Email content",
"emailContentHint": "HTML supported",
"emailContentPlaceholder": "<p>Hello, {{user_name}}!</p>\n<p>Your message here...</p>",
"emailVariables": "Available variables"
"emailVariables": "Available variables",
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "2 broadcasts will be created",
"bothCreated": "Broadcasts created",
"atLeastOneChannel": "Select at least one channel"
},
"settings": {
"title": "System Settings",
@@ -1267,6 +1278,7 @@
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
"settingsUpdateError": "Error saving settings",
"copyTelegramId": "Click to copy Telegram ID",
"viewUser": "Profile",
"cabinetNotifications": "Cabinet Notifications",
"userNotificationsEnabled": "User Notifications",
"userNotificationsEnabledDesc": "Send notifications to users about admin replies",
@@ -1808,7 +1820,7 @@
},
"confirmDelete": {
"title": "Delete user?",
"message": "Are you sure? This action is irreversible. All user data will be deleted."
"message": "Are you sure? This action is irreversible. The user will be fully deleted from the bot and from the Remnawave panel."
},
"confirmResetTrial": {
"title": "Reset trial?",
@@ -1828,14 +1840,36 @@
"info": "Information",
"subscription": "Subscription",
"balance": "Balance",
"sync": "Synchronization"
"sync": "Synchronization",
"tickets": "Tickets"
},
"noTickets": "No tickets from this user",
"ticketsCount": "tickets",
"messagesCount": "messages",
"status": "Status",
"language": "Language",
"registration": "Registration",
"lastActivity": "Last activity",
"totalSpent": "Total spent",
"purchases": "Purchases",
"campaign": "Advertising campaign",
"referralsList": "Referrals list",
"referralEarnings": "Referral earnings",
"noReferrals": "No referrals",
"panelConfig": "Panel configuration",
"trojanPassword": "Trojan password",
"vlessUuid": "VLESS UUID",
"ssPassword": "SS password",
"subscriptionUrl": "Subscription URL",
"happLink": "HAPP link",
"firstConnected": "First connected",
"lastOnline": "Last online",
"lastNode": "Last node",
"liveTraffic": "Current traffic",
"lifetime": "Lifetime",
"nodeUsage": "Node usage",
"copied": "Copied",
"panelNotFound": "User not found in panel",
"referral": {
"title": "Referral program",
"referrals": "Referrals",
@@ -1848,6 +1882,10 @@
"subscription": "• Subscription purchase prohibited",
"reason": "Reason"
},
"actions": {
"title": "Actions",
"areYouSure": "Are you sure?"
},
"subscription": {
"current": "Current subscription",
"tariff": "Tariff",

View File

@@ -181,7 +181,13 @@
"passwordTooShort": "رمز عبور باید حداقل ۸ کاراکتر باشد",
"sendResetLink": "ارسال لینک بازنشانی",
"tooManyAttempts": "تلاش‌های زیاد. لطفاً بعداً امتحان کنید",
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد"
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
"continueWithGoogle": "ادامه با Google",
"continueWithYandex": "ادامه با Yandex",
"continueWithDiscord": "ادامه با Discord",
"continueWithVk": "ادامه با VK",
"oauthError": "مجوز رد شد یا ناموفق بود",
"oauthExpired": "نشست OAuth منقضی شده است. لطفاً دوباره امتحان کنید."
},
"emailVerification": {
"title": "تایید ایمیل",
@@ -797,7 +803,12 @@
"activity": "بر اساس فعالیت",
"source": "بر اساس منبع",
"tariff": "بر اساس تعرفه"
}
},
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد",
"bothCreated": "پیام‌رسانی‌ها ایجاد شدند",
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید"
},
"payments": {
"title": "تأیید پرداخت",
@@ -979,6 +990,7 @@
"adminNotificationsEnabledDesc": "ارسال اعلان به مدیران درباره تیکت‌ها و پاسخ‌های جدید",
"cabinetNotifications": "اعلان‌های پنل",
"copyTelegramId": "برای کپی شناسه تلگرام کلیک کنید",
"viewUser": "کاربر",
"userNotificationsEnabled": "اعلان‌های کاربر",
"userNotificationsEnabledDesc": "ارسال اعلان به کاربران درباره پاسخ‌های مدیر"
},
@@ -1531,14 +1543,36 @@
"info": "اطلاعات",
"subscription": "اشتراک",
"balance": "موجودی",
"sync": "همگام‌سازی"
"sync": "همگام‌سازی",
"tickets": "تیکت‌ها"
},
"noTickets": "این کاربر تیکتی ندارد",
"ticketsCount": "تیکت",
"messagesCount": "پیام",
"status": "وضعیت",
"language": "زبان",
"registration": "ثبت‌نام",
"lastActivity": "آخرین فعالیت",
"totalSpent": "کل هزینه",
"purchases": "خریدها",
"campaign": "کمپین تبلیغاتی",
"referralsList": "لیست ارجاعات",
"referralEarnings": "درآمد ارجاع",
"noReferrals": "بدون ارجاع",
"panelConfig": "پیکربندی پنل",
"trojanPassword": "رمز Trojan",
"vlessUuid": "VLESS UUID",
"ssPassword": "رمز SS",
"subscriptionUrl": "لینک اشتراک",
"happLink": "لینک HAPP",
"firstConnected": "اولین اتصال",
"lastOnline": "آخرین آنلاین",
"lastNode": "آخرین نود",
"liveTraffic": "ترافیک فعلی",
"lifetime": "کل",
"nodeUsage": "مصرف نودها",
"copied": "کپی شد",
"panelNotFound": "کاربر در پنل یافت نشد",
"referral": {
"title": "برنامه ارجاع",
"referrals": "ارجاعات",

View File

@@ -190,7 +190,13 @@
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
"backToLogin": "Вернуться ко входу",
"emailNotVerified": "Сначала подтвердите email",
"loginSuccess": "Авторизация успешна"
"loginSuccess": "Авторизация успешна",
"continueWithGoogle": "Продолжить через Google",
"continueWithYandex": "Продолжить через Яндекс",
"continueWithDiscord": "Продолжить через Discord",
"continueWithVk": "Продолжить через VK",
"oauthError": "Авторизация отклонена или не удалась",
"oauthExpired": "Сессия OAuth истекла. Попробуйте снова."
},
"emailVerification": {
"title": "Подтверждение email",
@@ -1118,7 +1124,12 @@
"emailContent": "Содержимое письма",
"emailContentHint": "Поддерживается HTML",
"emailContentPlaceholder": "<p>Здравствуйте, {{user_name}}!</p>\n<p>Текст вашего письма...</p>",
"emailVariables": "Доступные переменные"
"emailVariables": "Доступные переменные",
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "Будет создано 2 рассылки",
"bothCreated": "Рассылки созданы",
"atLeastOneChannel": "Выберите хотя бы один канал"
},
"settings": {
"title": "Настройки системы",
@@ -1782,6 +1793,7 @@
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
"settingsUpdateError": "Ошибка сохранения настроек",
"copyTelegramId": "Нажмите чтобы скопировать Telegram ID",
"viewUser": "Карточка",
"cabinetNotifications": "Уведомления в кабинете",
"userNotificationsEnabled": "Уведомления для пользователей",
"userNotificationsEnabledDesc": "Отправлять уведомления пользователям об ответах админа",
@@ -2335,7 +2347,7 @@
},
"confirmDelete": {
"title": "Удалить пользователя?",
"message": "Вы уверены? Это действие необратимо. Все данные пользователя будут удалены."
"message": "Вы уверены? Это действие необратимо. Пользователь будет полностью удалён из бота и из панели Remnawave."
},
"confirmResetTrial": {
"title": "Сбросить триал?",
@@ -2355,14 +2367,35 @@
"info": "Информация",
"subscription": "Подписка",
"balance": "Баланс",
"sync": "Синхронизация"
"sync": "Синхронизация",
"tickets": "Тикеты"
},
"noTickets": "У пользователя нет тикетов",
"ticketsCount": "тикетов",
"messagesCount": "сообщений",
"status": "Статус",
"language": "Язык",
"registration": "Регистрация",
"lastActivity": "Последняя активность",
"totalSpent": "Всего потрачено",
"purchases": "Покупок",
"campaign": "Рекламная кампания",
"referralsList": "Список рефералов",
"noReferrals": "Нет рефералов",
"panelConfig": "Конфигурация панели",
"trojanPassword": "Trojan пароль",
"vlessUuid": "VLESS UUID",
"ssPassword": "SS пароль",
"subscriptionUrl": "Ссылка подписки",
"happLink": "HAPP ссылка",
"firstConnected": "Первое подключение",
"lastOnline": "Последний онлайн",
"lastNode": "Последняя нода",
"liveTraffic": "Текущий трафик",
"lifetime": "За всё время",
"nodeUsage": "Расход по нодам",
"copied": "Скопировано",
"panelNotFound": "Пользователь не найден в панели",
"referral": {
"title": "Реферальная программа",
"referrals": "Рефералов",
@@ -2375,6 +2408,10 @@
"subscription": "• Запрет покупки подписки",
"reason": "Причина"
},
"actions": {
"title": "Действия",
"areYouSure": "Уверены?"
},
"subscription": {
"current": "Текущая подписка",
"tariff": "Тариф",

View File

@@ -181,7 +181,13 @@
"passwordTooShort": "密码至少需要8个字符",
"sendResetLink": "发送重置链接",
"tooManyAttempts": "尝试次数过多,请稍后再试",
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱"
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
"continueWithGoogle": "通过 Google 继续",
"continueWithYandex": "通过 Yandex 继续",
"continueWithDiscord": "通过 Discord 继续",
"continueWithVk": "通过 VK 继续",
"oauthError": "授权被拒绝或失败",
"oauthExpired": "OAuth 会话已过期。请重试。"
},
"emailVerification": {
"title": "邮箱验证",
@@ -849,7 +855,12 @@
"activity": "按活动",
"source": "按来源",
"tariff": "按套餐"
}
},
"enableTelegram": "Telegram",
"enableEmail": "Email",
"sendingBoth": "将创建2个群发",
"bothCreated": "群发已创建",
"atLeastOneChannel": "请至少选择一个渠道"
},
"settings": {
"title": "系统设置",
@@ -1017,6 +1028,7 @@
"adminNotificationsEnabledDesc": "向管理员发送新工单和回复通知",
"cabinetNotifications": "面板通知",
"copyTelegramId": "点击复制Telegram ID",
"viewUser": "用户",
"userNotificationsEnabled": "用户通知",
"userNotificationsEnabledDesc": "向用户发送管理员回复通知"
},
@@ -1530,14 +1542,36 @@
"info": "信息",
"subscription": "订阅",
"balance": "余额",
"sync": "同步"
"sync": "同步",
"tickets": "工单"
},
"noTickets": "该用户没有工单",
"ticketsCount": "个工单",
"messagesCount": "条消息",
"status": "状态",
"language": "语言",
"registration": "注册时间",
"lastActivity": "最后活跃",
"totalSpent": "总消费",
"purchases": "购买次数",
"campaign": "广告活动",
"referralsList": "推荐列表",
"referralEarnings": "推荐收入",
"noReferrals": "暂无推荐",
"panelConfig": "面板配置",
"trojanPassword": "Trojan 密码",
"vlessUuid": "VLESS UUID",
"ssPassword": "SS 密码",
"subscriptionUrl": "订阅链接",
"happLink": "HAPP 链接",
"firstConnected": "首次连接",
"lastOnline": "最后在线",
"lastNode": "最后节点",
"liveTraffic": "当前流量",
"lifetime": "累计",
"nodeUsage": "节点用量",
"copied": "已复制",
"panelNotFound": "面板中未找到用户",
"referral": {
"title": "推荐计划",
"referrals": "推荐人数",

View File

@@ -6,7 +6,6 @@ import {
adminBroadcastsApi,
BroadcastFilter,
TariffFilter,
BroadcastChannel,
CombinedBroadcastCreateRequest,
} from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
@@ -118,12 +117,15 @@ export default function AdminBroadcastCreate() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
// Channel selection
const [channel, setChannel] = useState<BroadcastChannel>('telegram');
// Channel toggles (both can be enabled)
const [telegramEnabled, setTelegramEnabled] = useState(true);
const [emailEnabled, setEmailEnabled] = useState(false);
// Common state
const [target, setTarget] = useState('');
const [showFilters, setShowFilters] = useState(false);
// Separate targets per channel
const [telegramTarget, setTelegramTarget] = useState('');
const [emailTarget, setEmailTarget] = useState('');
const [showTelegramFilters, setShowTelegramFilters] = useState(false);
const [showEmailFilters, setShowEmailFilters] = useState(false);
// Telegram-specific state
const [messageText, setMessageText] = useState('');
@@ -138,29 +140,32 @@ export default function AdminBroadcastCreate() {
const [emailSubject, setEmailSubject] = useState('');
const [emailContent, setEmailContent] = useState('');
// Submitting state for dual send
const [isSubmitting, setIsSubmitting] = useState(false);
// Fetch Telegram filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters,
enabled: channel === 'telegram',
enabled: telegramEnabled,
});
// Fetch Email filters
const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'email-filters'],
queryFn: adminBroadcastsApi.getEmailFilters,
enabled: channel === 'email',
enabled: emailEnabled,
});
// Fetch buttons
const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons,
enabled: channel === 'telegram',
enabled: telegramEnabled,
});
// Preview mutations
const previewMutation = useMutation({
// Preview mutations — separate for each channel
const telegramPreviewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview,
});
@@ -168,7 +173,7 @@ export default function AdminBroadcastCreate() {
mutationFn: adminBroadcastsApi.previewEmail,
});
// Create mutation
// Create mutation (used for single-channel sends)
const createMutation = useMutation({
mutationFn: adminBroadcastsApi.createCombined,
onSuccess: (data) => {
@@ -178,7 +183,7 @@ export default function AdminBroadcastCreate() {
});
// Group Telegram filters
const groupedFilters = useMemo(() => {
const groupedTelegramFilters = useMemo(() => {
if (!filtersData) return {};
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
@@ -215,48 +220,46 @@ export default function AdminBroadcastCreate() {
return groups;
}, [emailFiltersData]);
// Current filters based on channel
const currentFilters = channel === 'telegram' ? groupedFilters : groupedEmailFilters;
const isFiltersLoading = channel === 'telegram' ? filtersLoading : emailFiltersLoading;
// Selected filter info for each channel
const selectedTelegramFilter = useMemo(() => {
if (!telegramTarget || !filtersData) return null;
const all = [
...filtersData.filters,
...filtersData.tariff_filters,
...filtersData.custom_filters,
];
return all.find((f) => f.key === telegramTarget) ?? null;
}, [telegramTarget, filtersData]);
// Selected filter info
const selectedFilter = useMemo(() => {
if (!target) return null;
const selectedEmailFilter = useMemo(() => {
if (!emailTarget || !emailFiltersData) return null;
return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null;
}, [emailTarget, emailFiltersData]);
if (channel === 'telegram' && filtersData) {
const all = [
...filtersData.filters,
...filtersData.tariff_filters,
...filtersData.custom_filters,
];
return all.find((f) => f.key === target);
}
// Handle toggling channels
const handleToggleTelegram = () => {
setTelegramEnabled((prev) => !prev);
setTelegramTarget('');
telegramPreviewMutation.reset();
};
if (channel === 'email' && emailFiltersData) {
return emailFiltersData.filters.find((f) => f.key === target);
}
return null;
}, [target, channel, filtersData, emailFiltersData]);
// Handle channel change
const handleChannelChange = (newChannel: BroadcastChannel) => {
setChannel(newChannel);
setTarget('');
previewMutation.reset();
const handleToggleEmail = () => {
setEmailEnabled((prev) => !prev);
setEmailTarget('');
emailPreviewMutation.reset();
};
// Handle filter selection
const handleFilterSelect = (filterKey: string) => {
setTarget(filterKey);
setShowFilters(false);
// Handle filter selection per channel
const handleTelegramFilterSelect = (filterKey: string) => {
setTelegramTarget(filterKey);
setShowTelegramFilters(false);
telegramPreviewMutation.mutate(filterKey);
};
if (channel === 'telegram') {
previewMutation.mutate(filterKey);
} else {
emailPreviewMutation.mutate(filterKey);
}
const handleEmailFilterSelect = (filterKey: string) => {
setEmailTarget(filterKey);
setShowEmailFilters(false);
emailPreviewMutation.mutate(filterKey);
};
// Handle file selection
@@ -308,53 +311,163 @@ export default function AdminBroadcastCreate() {
};
// Validate form
const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0;
const isEmailValid =
emailEnabled && emailTarget && emailSubject.trim().length > 0 && emailContent.trim().length > 0;
const isValid = useMemo(() => {
if (!target) return false;
if (!telegramEnabled && !emailEnabled) return false;
if (telegramEnabled && !isTelegramValid) return false;
if (emailEnabled && !isEmailValid) return false;
return true;
}, [telegramEnabled, emailEnabled, isTelegramValid, isEmailValid]);
if (channel === 'telegram') {
return messageText.trim().length > 0;
}
if (channel === 'email') {
return emailSubject.trim().length > 0 && emailContent.trim().length > 0;
}
return false;
}, [target, channel, messageText, emailSubject, emailContent]);
const bothChannels = telegramEnabled && emailEnabled;
// Submit
const handleSubmit = () => {
const handleSubmit = async () => {
if (!isValid) return;
const data: CombinedBroadcastCreateRequest = {
channel,
target,
};
if (channel === 'telegram') {
data.message_text = messageText;
data.selected_buttons = selectedButtons;
// Single channel — use existing createMutation with navigation to detail
if (telegramEnabled && !emailEnabled) {
const data: CombinedBroadcastCreateRequest = {
channel: 'telegram',
target: telegramTarget,
message_text: messageText,
selected_buttons: selectedButtons,
};
if (uploadedFileId) {
data.media = {
type: mediaType,
file_id: uploadedFileId,
};
data.media = { type: mediaType, file_id: uploadedFileId };
}
createMutation.mutate(data);
return;
}
if (channel === 'email') {
data.email_subject = emailSubject;
data.email_html_content = emailContent;
if (emailEnabled && !telegramEnabled) {
const data: CombinedBroadcastCreateRequest = {
channel: 'email',
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
};
createMutation.mutate(data);
return;
}
createMutation.mutate(data);
// Both channels — two sequential requests, navigate to list
setIsSubmitting(true);
try {
const telegramData: CombinedBroadcastCreateRequest = {
channel: 'telegram',
target: telegramTarget,
message_text: messageText,
selected_buttons: selectedButtons,
};
if (uploadedFileId) {
telegramData.media = { type: mediaType, file_id: uploadedFileId };
}
const emailData: CombinedBroadcastCreateRequest = {
channel: 'email',
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
};
await adminBroadcastsApi.createCombined(telegramData);
await adminBroadcastsApi.createCombined(emailData);
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
navigate('/admin/broadcasts');
} finally {
setIsSubmitting(false);
}
};
const recipientsCount =
channel === 'telegram'
? (previewMutation.data?.count ?? selectedFilter?.count ?? null)
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null);
// Recipients counts per channel
const telegramRecipientsCount = telegramEnabled
? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.count ?? null)
: null;
const emailRecipientsCount = emailEnabled
? (emailPreviewMutation.data?.count ?? selectedEmailFilter?.count ?? null)
: null;
const isPending = createMutation.isPending || isSubmitting;
// Render filter dropdown
const renderFilterDropdown = (
channelType: 'telegram' | 'email',
target: string,
selectedFilter: BroadcastFilter | TariffFilter | null,
recipientsCount: number | null,
showFilters: boolean,
setShowFilters: (v: boolean) => void,
handleFilterSelect: (key: string) => void,
groupedFilters: Record<string, (BroadcastFilter | TariffFilter)[]>,
isLoading: boolean,
) => (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{channelType === 'telegram'
? t('admin.broadcasts.selectFilter')
: t('admin.broadcasts.selectEmailFilter')}
</label>
<div className="relative">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center gap-2">
<UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter
? selectedFilter.label
: channelType === 'telegram'
? t('admin.broadcasts.selectFilterPlaceholder')
: t('admin.broadcasts.selectEmailFilterPlaceholder')}
</span>
{recipientsCount !== null && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{recipientsCount} {t('admin.broadcasts.recipients')}
</span>
)}
</div>
<ChevronDownIcon />
</button>
{showFilters && (
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl">
{isLoading ? (
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
) : (
Object.entries(groupedFilters).map(([group, filters]) => (
<div key={group}>
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400">
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
</div>
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => handleFilterSelect(filter.key)}
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
target === filter.key ? 'bg-accent-500/20' : ''
}`}
>
<span className="text-dark-100">{filter.label}</span>
{filter.count !== null && filter.count !== undefined && (
<span className="text-xs text-dark-400">{filter.count}</span>
)}
</button>
))}
</div>
))
)}
</div>
)}
</div>
</div>
);
return (
<div className="space-y-6">
@@ -372,273 +485,250 @@ export default function AdminBroadcastCreate() {
</div>
</div>
{/* Channel selection */}
{/* Channel toggles */}
<div className="card">
<label className="mb-3 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.selectChannel')}
</label>
<div className="flex gap-3">
<button
onClick={() => handleChannelChange('telegram')}
onClick={handleToggleTelegram}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
channel === 'telegram'
telegramEnabled
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
<TelegramIcon />
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span>
<span className="font-medium">{t('admin.broadcasts.enableTelegram')}</span>
</button>
<button
onClick={() => handleChannelChange('email')}
onClick={handleToggleEmail}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
channel === 'email'
emailEnabled
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
<EmailIcon />
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span>
<span className="font-medium">{t('admin.broadcasts.enableEmail')}</span>
</button>
</div>
{!telegramEnabled && !emailEnabled && (
<p className="mt-2 text-sm text-error-400">{t('admin.broadcasts.atLeastOneChannel')}</p>
)}
{bothChannels && (
<p className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.sendingBoth')}</p>
)}
</div>
{/* Content */}
<div className="card space-y-6">
{/* Section title */}
<h2 className="text-lg font-semibold text-dark-100">
{channel === 'telegram'
? t('admin.broadcasts.telegramSection')
: t('admin.broadcasts.emailSection')}
</h2>
{/* Telegram section */}
{telegramEnabled && (
<div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.broadcasts.telegramSection')}
</h2>
{/* Filter selection */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{channel === 'telegram'
? t('admin.broadcasts.selectFilter')
: t('admin.broadcasts.selectEmailFilter')}
</label>
<div className="relative">
<button
onClick={() => setShowFilters(!showFilters)}
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center gap-2">
<UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter
? selectedFilter.label
: channel === 'telegram'
? t('admin.broadcasts.selectFilterPlaceholder')
: t('admin.broadcasts.selectEmailFilterPlaceholder')}
</span>
{recipientsCount !== null && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{recipientsCount} {t('admin.broadcasts.recipients')}
</span>
{/* Telegram filter selection */}
{renderFilterDropdown(
'telegram',
telegramTarget,
selectedTelegramFilter,
telegramRecipientsCount,
showTelegramFilters,
setShowTelegramFilters,
handleTelegramFilterSelect,
groupedTelegramFilters,
filtersLoading,
)}
{/* Message text */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.messageText')}
</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={6}
maxLength={4000}
className="input min-h-[150px] resize-y"
/>
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
</div>
{/* Media upload */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.media')}
</label>
{mediaFile ? (
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />}
{mediaType === 'video' && <VideoIcon />}
{mediaType === 'document' && <DocumentIcon />}
<div>
<p className="text-sm text-dark-100">{mediaFile.name}</p>
<p className="text-xs text-dark-400">
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={handleRemoveMedia}
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
disabled={isUploading}
>
<XIcon />
</button>
</div>
{mediaPreview && (
<img
src={mediaPreview}
alt="Preview"
className="mt-3 max-h-48 rounded-lg object-cover"
/>
)}
{isUploading && (
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
<RefreshIcon />
{t('admin.broadcasts.uploading')}
</div>
)}
</div>
<ChevronDownIcon />
</button>
{showFilters && (
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl">
{isFiltersLoading ? (
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
) : (
Object.entries(currentFilters).map(([group, filters]) => (
<div key={group}>
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400">
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
</div>
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => handleFilterSelect(filter.key)}
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
target === filter.key ? 'bg-accent-500/20' : ''
}`}
>
<span className="text-dark-100">{filter.label}</span>
{filter.count !== null && filter.count !== undefined && (
<span className="text-xs text-dark-400">{filter.count}</span>
)}
</button>
))}
</div>
))
)}
) : (
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,application/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
>
<PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span>
</button>
</div>
)}
</div>
{/* Buttons selection */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.buttons')}
</label>
<div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map((button) => (
<button
key={button.key}
onClick={() => toggleButton(button.key)}
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
selectedButtons.includes(button.key)
? 'bg-accent-500 text-white'
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{button.label}
</button>
))}
</div>
</div>
</div>
)}
{/* Telegram-specific fields */}
{channel === 'telegram' && (
<>
{/* Message text */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.messageText')}
</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={6}
maxLength={4000}
className="input min-h-[150px] resize-y"
/>
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
</div>
{/* Email section */}
{emailEnabled && (
<div className="card space-y-6">
<h2 className="text-lg font-semibold text-dark-100">
{t('admin.broadcasts.emailSection')}
</h2>
{/* Media upload */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.media')}
</label>
{mediaFile ? (
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />}
{mediaType === 'video' && <VideoIcon />}
{mediaType === 'document' && <DocumentIcon />}
<div>
<p className="text-sm text-dark-100">{mediaFile.name}</p>
<p className="text-xs text-dark-400">
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={handleRemoveMedia}
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
disabled={isUploading}
>
<XIcon />
</button>
</div>
{mediaPreview && (
<img
src={mediaPreview}
alt="Preview"
className="mt-3 max-h-48 rounded-lg object-cover"
/>
)}
{isUploading && (
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
<RefreshIcon />
{t('admin.broadcasts.uploading')}
</div>
)}
</div>
) : (
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,application/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
>
<PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span>
</button>
</div>
)}
</div>
{/* Email filter selection */}
{renderFilterDropdown(
'email',
emailTarget,
selectedEmailFilter,
emailRecipientsCount,
showEmailFilters,
setShowEmailFilters,
handleEmailFilterSelect,
groupedEmailFilters,
emailFiltersLoading,
)}
{/* Buttons selection */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.buttons')}
</label>
<div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map((button) => (
<button
key={button.key}
onClick={() => toggleButton(button.key)}
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
selectedButtons.includes(button.key)
? 'bg-accent-500 text-white'
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{button.label}
</button>
))}
</div>
</div>
</>
)}
{/* Email subject */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailSubject')}
</label>
<input
type="text"
value={emailSubject}
onChange={(e) => setEmailSubject(e.target.value)}
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
className="input"
maxLength={200}
/>
</div>
{/* Email-specific fields */}
{channel === 'email' && (
<>
{/* Email subject */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailSubject')}
</label>
<input
type="text"
value={emailSubject}
onChange={(e) => setEmailSubject(e.target.value)}
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
className="input"
maxLength={200}
/>
</div>
{/* Email content */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailContent')}
</label>
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
<textarea
value={emailContent}
onChange={(e) => setEmailContent(e.target.value)}
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
rows={10}
className="input min-h-[200px] resize-y font-mono text-sm"
/>
</div>
{/* Email content */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailContent')}
</label>
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
<textarea
value={emailContent}
onChange={(e) => setEmailContent(e.target.value)}
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
rows={10}
className="input min-h-[200px] resize-y font-mono text-sm"
/>
{/* Email variables hint */}
<div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4">
<p className="mb-2 text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailVariables')}
</p>
<div className="flex flex-wrap gap-2">
{['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
<code
key={variable}
className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
>
{variable}
</code>
))}
</div>
{/* Email variables hint */}
<div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4">
<p className="mb-2 text-sm font-medium text-dark-300">
{t('admin.broadcasts.emailVariables')}
</p>
<div className="flex flex-wrap gap-2">
{['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
<code
key={variable}
className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
>
{variable}
</code>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>
)}
{/* Footer */}
<div className="card flex items-center justify-between">
<div className="text-sm text-dark-400">
{recipientsCount !== null && (
{(telegramRecipientsCount !== null || emailRecipientsCount !== null) && (
<span>
{t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
{t('admin.broadcasts.users')}
{telegramRecipientsCount !== null && (
<>
<strong className="text-accent-400">{telegramRecipientsCount}</strong> (TG)
</>
)}
{telegramRecipientsCount !== null && emailRecipientsCount !== null && ' + '}
{emailRecipientsCount !== null && (
<>
<strong className="text-accent-400">{emailRecipientsCount}</strong> (Email)
</>
)}
</span>
)}
</div>
@@ -648,10 +738,10 @@ export default function AdminBroadcastCreate() {
</button>
<button
onClick={handleSubmit}
disabled={!isValid || createMutation.isPending || isUploading}
disabled={!isValid || isPending || isUploading}
className="btn-primary flex items-center gap-2"
>
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
{isPending ? <RefreshIcon /> : <BroadcastIcon />}
{t('admin.broadcasts.send')}
</button>
</div>

View File

@@ -6,7 +6,6 @@ import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PromoGroupSimple } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
@@ -19,21 +18,6 @@ const BackIcon = () => (
</svg>
);
const METHOD_ICONS: Record<string, string> = {
telegram_stars: '⭐',
tribute: '🎁',
cryptobot: '🪙',
heleket: '⚡',
yookassa: '🏦',
mulenpay: '💳',
pal24: '💸',
platega: '💰',
wata: '💧',
freekassa: '💵',
cloudpayments: '☁️',
kassa_ai: '🏦',
};
const METHOD_LABELS: Record<string, string> = {
telegram_stars: 'Telegram Stars',
tribute: 'Tribute',
@@ -195,7 +179,6 @@ export default function AdminPaymentMethodEdit() {
}
const displayName = config.display_name || config.default_display_name;
const icon = METHOD_ICONS[config.method_id] || '💳';
return (
<div className="space-y-6">
@@ -210,9 +193,6 @@ export default function AdminPaymentMethodEdit() {
<BackIcon />
</button>
)}
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
{icon}
</div>
<div>
<h1 className="text-2xl font-bold text-dark-50">{displayName}</h1>
<p className="text-sm text-dark-500">

View File

@@ -23,7 +23,6 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PaymentMethodConfig } from '../types';
// ============ Icons ============
const BackIcon = () => (
@@ -64,23 +63,6 @@ const SaveIcon = () => (
</svg>
);
// ============ Method icon by type ============
const METHOD_ICONS: Record<string, string> = {
telegram_stars: '\u2B50',
tribute: '\uD83C\uDF81',
cryptobot: '\uD83E\uDE99',
heleket: '\u26A1',
yookassa: '\uD83C\uDFE6',
mulenpay: '\uD83D\uDCB3',
pal24: '\uD83D\uDCB8',
platega: '\uD83D\uDCB0',
wata: '\uD83D\uDCA7',
freekassa: '\uD83D\uDCB5',
cloudpayments: '\u2601\uFE0F',
kassa_ai: '\uD83C\uDFE6',
};
// ============ Sortable Card ============
interface SortableCardProps {
@@ -102,7 +84,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
};
const displayName = config.display_name || config.default_display_name;
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
// Build condition summary chips
const chips: string[] = [];
@@ -152,11 +133,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
<GripIcon />
</button>
{/* Method icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
{icon}
</div>
{/* Content */}
<div className="min-w-0 flex-1 cursor-pointer" onClick={onClick}>
<div className="flex flex-wrap items-center gap-2">
@@ -337,9 +313,6 @@ export default function AdminPaymentMethods() {
</DndContext>
) : (
<div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
</div>
<div className="text-dark-400">{t('admin.paymentMethods.noMethods')}</div>
</div>
)}

View File

@@ -434,19 +434,29 @@ export default function AdminTickets() {
</span>
</div>
</div>
<div className="mb-4 text-sm text-dark-500">
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
{selectedTicket.user?.telegram_id && (
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
<span>
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
{selectedTicket.user?.telegram_id && (
<button
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
className="ml-1 rounded bg-dark-700 px-2 py-0.5 text-xs transition-colors hover:bg-dark-600"
title={t('admin.tickets.copyTelegramId')}
>
TG: {selectedTicket.user!.telegram_id}
</button>
)}{' '}
| {t('admin.tickets.created')}:{' '}
{new Date(selectedTicket.created_at).toLocaleString()}
</span>
{selectedTicket.user && (
<button
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
className="ml-1 rounded bg-dark-700 px-2 py-0.5 text-xs transition-colors hover:bg-dark-600"
title={t('admin.tickets.copyTelegramId')}
onClick={() => navigate(`/admin/users/${selectedTicket.user!.id}`)}
className="shrink-0 rounded-lg border border-accent-500/30 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400 transition-colors hover:bg-accent-500/20"
>
TG: {selectedTicket.user!.telegram_id}
{t('admin.tickets.viewUser')}
</button>
)}{' '}
| {t('admin.tickets.created')}:{' '}
{new Date(selectedTicket.created_at).toLocaleString()}
)}
</div>
<div className="flex flex-wrap gap-2">
{['open', 'pending', 'answered', 'closed'].map((s) => (

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency';
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useNotify } from '../platform/hooks/useNotify';
// ============ Icons ============
@@ -58,204 +57,6 @@ const TelegramIcon = () => (
</svg>
);
const DotsVerticalIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const ArrowPathIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const NoSymbolIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
);
const ExclamationTriangleIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
// ============ Confirmation Modal ============
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText: string;
confirmButtonClass?: string;
isLoading?: boolean;
}
function ConfirmationModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
confirmButtonClass = 'bg-rose-500 hover:bg-rose-600',
isLoading = false,
}: ConfirmationModalProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 backdrop-blur-sm">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-800 p-6 shadow-xl">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/20 text-rose-400">
<ExclamationTriangleIcon />
</div>
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
</div>
<p className="mb-6 text-dark-300">{message}</p>
<div className="flex gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 py-2.5 text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className={`flex-1 rounded-xl py-2.5 text-white transition-colors disabled:opacity-50 ${confirmButtonClass}`}
>
{isLoading ? t('common.loading') : confirmText}
</button>
</div>
</div>
</div>
);
}
// ============ User Actions Menu ============
type UserAction = 'delete' | 'resetTrial' | 'resetSubscription' | 'disable';
interface UserActionsMenuProps {
user: UserListItem;
onAction: (action: UserAction, user: UserListItem) => void;
}
function UserActionsMenu({ user, onAction }: UserActionsMenuProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleAction = (action: UserAction) => {
setIsOpen(false);
onAction(action, user);
};
const actions = [
{
key: 'resetTrial' as const,
label: t('admin.users.userActions.resetTrial'),
icon: <ArrowPathIcon />,
className: 'text-blue-400 hover:bg-blue-500/10',
},
{
key: 'resetSubscription' as const,
label: t('admin.users.userActions.resetSubscription'),
icon: <ArrowPathIcon />,
className: 'text-amber-400 hover:bg-amber-500/10',
},
{
key: 'disable' as const,
label: t('admin.users.userActions.disable'),
icon: <NoSymbolIcon />,
className: 'text-dark-400 hover:bg-dark-700',
},
{
key: 'delete' as const,
label: t('admin.users.userActions.delete'),
icon: <TrashIcon />,
className: 'text-rose-400 hover:bg-rose-500/10',
},
];
return (
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
>
<DotsVerticalIcon />
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{actions.map((action) => (
<button
key={action.key}
onClick={(e) => {
e.stopPropagation();
handleAction(action.key);
}}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${action.className}`}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
</div>
);
}
// ============ Components ============
interface StatCardProps {
@@ -305,11 +106,10 @@ function StatusBadge({ status }: { status: string }) {
interface UserRowProps {
user: UserListItem;
onClick: () => void;
onAction: (action: UserAction, user: UserListItem) => void;
formatAmount: (rubAmount: number) => string;
}
function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
function UserRow({ user, onClick, formatAmount }: UserRowProps) {
const { t } = useTranslation();
return (
<div
@@ -372,29 +172,17 @@ function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
</div>
</div>
{/* Actions Menu - hide chevron on mobile, show only dots */}
<UserActionsMenu user={user} onAction={onAction} />
<div className="hidden sm:block">
<ChevronRightIcon />
</div>
<ChevronRightIcon />
</div>
);
}
// ============ Main Page ============
interface ConfirmModalState {
isOpen: boolean;
action: UserAction | null;
user: UserListItem | null;
}
export default function AdminUsers() {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const navigate = useNavigate();
const notify = useNotify();
const { capabilities } = usePlatform();
const [users, setUsers] = useState<UserListItem[]>([]);
@@ -406,12 +194,6 @@ export default function AdminUsers() {
const [sortBy, setSortBy] = useState<string>('created_at');
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
isOpen: false,
action: null,
user: null,
});
const [actionLoading, setActionLoading] = useState(false);
const limit = 20;
@@ -458,92 +240,6 @@ export default function AdminUsers() {
loadUsers();
};
const handleUserAction = (action: UserAction, user: UserListItem) => {
setConfirmModal({ isOpen: true, action, user });
};
const closeConfirmModal = () => {
setConfirmModal({ isOpen: false, action: null, user: null });
};
const executeAction = async () => {
const { action, user } = confirmModal;
if (!action || !user) return;
setActionLoading(true);
try {
let result: { success: boolean; message: string };
switch (action) {
case 'delete':
result = await adminUsersApi.deleteUser(user.id);
break;
case 'resetTrial':
result = await adminUsersApi.resetTrial(user.id);
break;
case 'resetSubscription':
result = await adminUsersApi.resetSubscription(user.id);
break;
case 'disable':
result = await adminUsersApi.disableUser(user.id);
break;
default:
throw new Error('Unknown action');
}
if (result.success) {
notify.success(t(`admin.users.userActions.success.${action}`), t('common.success'));
loadUsers();
loadStats();
} else {
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
}
} catch (error) {
console.error('Action failed:', error);
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
closeConfirmModal();
}
};
const getConfirmModalContent = () => {
const { action } = confirmModal;
if (!action) return { title: '', message: '', confirmText: '', confirmButtonClass: '' };
const configs: Record<
UserAction,
{ title: string; message: string; confirmText: string; confirmButtonClass: string }
> = {
delete: {
title: t('admin.users.userActions.confirmDelete.title'),
message: t('admin.users.userActions.confirmDelete.message'),
confirmText: t('admin.users.userActions.delete'),
confirmButtonClass: 'bg-rose-500 hover:bg-rose-600',
},
resetTrial: {
title: t('admin.users.userActions.confirmResetTrial.title'),
message: t('admin.users.userActions.confirmResetTrial.message'),
confirmText: t('admin.users.userActions.resetTrial'),
confirmButtonClass: 'bg-blue-500 hover:bg-blue-600',
},
resetSubscription: {
title: t('admin.users.userActions.confirmResetSubscription.title'),
message: t('admin.users.userActions.confirmResetSubscription.message'),
confirmText: t('admin.users.userActions.resetSubscription'),
confirmButtonClass: 'bg-amber-500 hover:bg-amber-600',
},
disable: {
title: t('admin.users.userActions.confirmDisable.title'),
message: t('admin.users.userActions.confirmDisable.message'),
confirmText: t('admin.users.userActions.disable'),
confirmButtonClass: 'bg-dark-600 hover:bg-dark-500',
},
};
return configs[action];
};
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
@@ -682,7 +378,6 @@ export default function AdminUsers() {
key={user.id}
user={user}
onClick={() => navigate(`/admin/users/${user.id}`)}
onAction={handleUserAction}
formatAmount={(amount) => formatWithCurrency(amount)}
/>
))
@@ -720,15 +415,6 @@ export default function AdminUsers() {
</div>
</div>
)}
{/* User action confirmation modal */}
<ConfirmationModal
isOpen={confirmModal.isOpen}
onClose={closeConfirmModal}
onConfirm={executeAction}
isLoading={actionLoading}
{...getConfirmModalContent()}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { useBlockingStore } from '../store/blocking';
import { subscriptionApi } from '../api/subscription';
import { referralApi } from '../api/referral';
import { balanceApi } from '../api/balance';
@@ -54,6 +55,7 @@ export default function Dashboard() {
const [trialError, setTrialError] = useState<string | null>(null);
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
const [showOnboarding, setShowOnboarding] = useState(false);
const { blockingType } = useBlockingStore();
// Refresh user data on mount
useEffect(() => {
@@ -185,13 +187,14 @@ export default function Dashboard() {
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
// Show onboarding for new users after data loads
// Skip if a blocking screen is active (channel subscription, maintenance, etc.)
useEffect(() => {
if (!isOnboardingCompleted && !subLoading && !refLoading) {
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
// Small delay to ensure DOM is ready
const timer = setTimeout(() => setShowOnboarding(true), 500);
return () => clearTimeout(timer);
}
}, [isOnboardingCompleted, subLoading, refLoading]);
}, [isOnboardingCompleted, subLoading, refLoading, blockingType]);
// Define onboarding steps based on available data
const onboardingSteps = useMemo(() => {

View File

@@ -93,39 +93,47 @@ const sanitizeHtml = (html: string): string => {
'blockquote',
'code',
'pre',
's',
'del',
'ins',
'span',
'div',
'tg-spoiler',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOW_DATA_ATTR: false,
});
};
// Convert plain text to HTML with proper formatting
// Convert content to formatted HTML (handles Telegram HTML + plain text)
const formatContent = (content: string): string => {
if (!content) return '';
// Check if content already has HTML tags
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content);
// Check if content has block-level HTML (full HTML document)
const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
if (hasHtmlTags) {
if (hasBlockHtml) {
return sanitizeHtml(content);
}
// Convert plain text to formatted HTML
// Content may have inline Telegram HTML (<b>, <i>, <u>, <code>, <a>) but uses
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
const result = content
.split(/\n\n+/) // Split by double newlines (paragraphs)
.split(/\n\n+/)
.map((paragraph) => {
// Check if it's a header (starts with # or numeric like "1.")
if (/^#{1,4}\s/.test(paragraph)) {
const level = paragraph.match(/^(#{1,4})/)?.[1].length || 1;
const text = paragraph.replace(/^#{1,4}\s*/, '');
const trimmed = paragraph.trim();
if (!trimmed) return '';
// Check if it's a markdown header
if (/^#{1,4}\s/.test(trimmed)) {
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
const text = trimmed.replace(/^#{1,4}\s*/, '');
return `<h${level}>${text}</h${level}>`;
}
// Check for list items
if (/^[-•]\s/.test(paragraph) || /^\d+[.)]\s/.test(paragraph)) {
const lines = paragraph.split('\n');
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
const lines = trimmed.split('\n');
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
const listItems = lines
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
@@ -135,11 +143,11 @@ const formatContent = (content: string): string => {
return isOrdered ? `<ol>${listItems}</ol>` : `<ul>${listItems}</ul>`;
}
// Regular paragraph - handle single line breaks within
const formattedParagraph = paragraph.split('\n').join('<br/>');
return `<p>${formattedParagraph}</p>`;
// Regular paragraph single newlines become <br/>
const formatted = trimmed.split('\n').join('<br/>');
return `<p>${formatted}</p>`;
})
.filter(Boolean)
.join('');
return sanitizeHtml(result);

View File

@@ -18,6 +18,8 @@ import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from './OAuthCallback';
export default function Login() {
const { t } = useTranslation();
@@ -92,6 +94,29 @@ export default function Login() {
});
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
// Fetch enabled OAuth providers
const { data: oauthData } = useQuery({
queryKey: ['oauth-providers'],
queryFn: authApi.getOAuthProviders,
staleTime: 60000,
});
const oauthProviders = oauthData?.providers ?? [];
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
const handleOAuthLogin = async (provider: string) => {
setError('');
setOauthLoading(provider);
try {
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
saveOAuthState(state, provider);
window.location.href = authorize_url;
} catch {
setError(t('auth.oauthError', 'Authorization was denied or failed'));
setOauthLoading(null);
}
};
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
// If email auth is disabled but user came with ref param, redirect to bot
@@ -424,6 +449,38 @@ export default function Login() {
)}
</div>
{/* OAuth providers section */}
{oauthProviders.length > 0 && (
<>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-dark-700" />
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
<div className="h-px flex-1 bg-dark-700" />
</div>
<div className="space-y-3">
{oauthProviders.map((provider) => (
<button
key={provider.name}
type="button"
onClick={() => handleOAuthLogin(provider.name)}
disabled={oauthLoading !== null}
className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50"
>
{oauthLoading === provider.name ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
) : (
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
)}
{t(
`auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`,
`Continue with ${provider.display_name}`,
)}
</button>
))}
</div>
</>
)}
{/* Email auth section - only when enabled */}
{isEmailAuthEnabled && (
<>

119
src/pages/OAuthCallback.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
// SessionStorage helpers for OAuth state
const OAUTH_STATE_KEY = 'oauth_state';
const OAUTH_PROVIDER_KEY = 'oauth_provider';
export function saveOAuthState(state: string, provider: string): void {
sessionStorage.setItem(OAUTH_STATE_KEY, state);
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
}
export function getAndClearOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
sessionStorage.removeItem(OAUTH_STATE_KEY);
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
if (!state || !provider) return null;
return { state, provider };
}
export default function OAuthCallback() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState('');
const { loginWithOAuth, isAuthenticated } = useAuthStore();
useEffect(() => {
if (isAuthenticated) {
navigate('/', { replace: true });
return;
}
const authenticate = async () => {
const code = searchParams.get('code');
const urlState = searchParams.get('state');
if (!code || !urlState) {
setError(t('auth.oauthError', 'Authorization was denied or failed'));
return;
}
// Get saved state from sessionStorage
const saved = getAndClearOAuthState();
if (!saved) {
setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.'));
return;
}
// Validate state match
if (saved.state !== urlState) {
setError(t('auth.oauthError', 'Authorization was denied or failed'));
return;
}
try {
await loginWithOAuth(saved.provider, code, urlState);
navigate('/', { replace: true });
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } };
setError(
error.response?.data?.detail ||
t('auth.oauthError', 'Authorization was denied or failed'),
);
}
};
authenticate();
}, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative w-full max-w-md text-center">
<div className="card">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-error-500/20">
<svg
className="h-8 w-8 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<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>
</div>
<h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2>
<p className="mb-6 text-sm text-dark-400">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full"
>
{t('auth.backToLogin', 'Back to login')}
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
<div className="relative text-center">
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
<h2 className="text-lg font-semibold text-dark-50">{t('auth.authenticating')}</h2>
<p className="mt-2 text-sm text-dark-400">{t('common.loading')}</p>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { Card } from '@/components/data-display/Card';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import PaymentMethodIcon from '@/components/PaymentMethodIcon';
export default function TopUpMethodSelect() {
const { t } = useTranslation();
@@ -70,8 +71,11 @@ export default function TopUpMethodSelect() {
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
onClick={() => method.is_available && handleMethodClick(method.id)}
>
<div className="font-semibold text-dark-100">
{translatedName || method.name}
<div className="flex items-center gap-3">
<PaymentMethodIcon method={methodKey} className="h-8 w-8 flex-shrink-0" />
<div className="font-semibold text-dark-100">
{translatedName || method.name}
</div>
</div>
{(translatedDesc || method.description) && (
<div className="mt-1 text-sm text-dark-500">

View File

@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
import FortuneWheel from '../components/wheel/FortuneWheel';
import WheelLegend from '../components/wheel/WheelLegend';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { usePlatform, useHaptic } from '@/platform';
import { useNotify } from '@/platform/hooks/useNotify';
import { Card } from '@/components/data-display/Card/Card';
@@ -461,11 +460,13 @@ export default function Wheel() {
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
const bothMethodsAvailable = !!(starsEnabled && daysEnabled);
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
const spinDisabled =
!config.can_spin ||
isSpinning ||
isPayingStars ||
(config.daily_limit > 0 && config.user_spins_today >= config.daily_limit);
dailyLimitReached ||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
return (
<div className="animate-fade-in space-y-6 pb-8">
@@ -570,25 +571,21 @@ export default function Wheel() {
</Button>
)}
{/* Cannot spin hint */}
{!config.can_spin && !isSpinning && (
<>
{config.can_spin_reason === 'daily_limit_reached' ? (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
) : config.can_spin_reason === 'insufficient_balance' ? (
<InsufficientBalancePrompt
missingAmountKopeks={
config.required_balance_kopeks - config.user_balance_kopeks
}
/>
) : (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
</div>
)}
</>
{/* Cannot spin hint — only show for days payment (Stars via invoice always works) */}
{!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">
{config.can_spin_reason === 'daily_limit_reached'
? t('wheel.errors.dailyLimitReached')
: t('wheel.errors.cannotSpin')}
</p>
</div>
)}
{/* Daily limit hint for Stars payment (not covered by can_spin check) */}
{!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && (
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
)}
{/* Inline Result Card */}

View File

@@ -33,6 +33,7 @@ interface AuthState {
loginWithTelegram: (initData: string) => Promise<void>;
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
loginWithEmail: (email: string, password: string) => Promise<void>;
loginWithOAuth: (provider: string, code: string, state: string) => Promise<void>;
registerWithEmail: (
email: string,
password: string,
@@ -267,6 +268,18 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus();
},
loginWithOAuth: async (provider, code, state) => {
const response = await authApi.oauthCallback(provider, code, state);
tokenStorage.setTokens(response.access_token, response.refresh_token);
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
user: response.user,
isAuthenticated: true,
});
await get().checkAdminStatus();
},
registerWithEmail: async (email, password, firstName, referralCode) => {
// Registration now returns message, not tokens
// User must verify email before they can login

View File

@@ -270,6 +270,14 @@
}
}
/* Twemoji — cross-platform emoji rendering */
img.twemoji {
display: inline-block;
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
}
@layer components {
/* ========== BENTO DESIGN SYSTEM ========== */
@@ -845,6 +853,19 @@
@apply italic text-dark-300;
}
.prose u {
@apply underline underline-offset-2;
}
.prose s,
.prose del {
@apply text-dark-400 line-through;
}
.prose tg-spoiler {
@apply cursor-pointer rounded bg-dark-600 px-1 text-transparent transition-colors hover:bg-transparent hover:text-dark-200;
}
.prose blockquote {
@apply my-4 rounded-r-lg border-l-4 border-accent-500/50 bg-dark-800/30 py-2 pl-4 italic text-dark-300;
}
@@ -921,6 +942,15 @@
@apply text-champagne-700;
}
.light .prose s,
.light .prose del {
@apply text-champagne-500;
}
.light .prose tg-spoiler {
@apply bg-champagne-300 text-transparent hover:bg-transparent hover:text-champagne-800;
}
.light .prose blockquote {
@apply border-champagne-400 bg-champagne-100/50 text-champagne-700;
}

View File

@@ -12,7 +12,13 @@ export interface User {
referral_code: string | null;
language: string;
created_at: string;
auth_type: 'telegram' | 'email'; // Тип аутентификации
auth_type: 'telegram' | 'email' | 'google' | 'yandex' | 'discord' | 'vk'; // Тип аутентификации
}
// OAuth types
export interface OAuthProvider {
name: string;
display_name: string;
}
// Auth types