english version update
This commit is contained in:
@@ -20,100 +20,206 @@ import ServerError from './pages/500';
|
||||
import BadGateway from './pages/502';
|
||||
import ServiceUnavailable from './pages/503';
|
||||
import GatewayTimeout from './pages/504';
|
||||
import ErrorPage from './pages/errors';
|
||||
import NetworkError from './pages/errors/NetworkError';
|
||||
import Privateroute from './components/privateroute';
|
||||
import { AuthProvider } from './context/authcontext';
|
||||
import { WebSocketProvider } from './context/WebSocketContext';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { LocaleProvider } from './middleware';
|
||||
import { LocaleProvider, useLocale } from './middleware';
|
||||
|
||||
// SEO конфиг для всех маршрутов
|
||||
// SEO конфиг для всех маршрутов с поддержкой локализации
|
||||
const SEO_CONFIG: Record<string, {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og?: {
|
||||
ru: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
keywords: string;
|
||||
og?: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
en: {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og?: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}> = {
|
||||
'/': {
|
||||
title: 'Облачное S3 хранилище',
|
||||
description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.',
|
||||
keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage',
|
||||
og: {
|
||||
title: 'ospab.host - Облачное S3 хранилище',
|
||||
description: 'S3-совместимое хранилище с поддержкой 24/7',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/',
|
||||
ru: {
|
||||
title: 'Облачное S3 хранилище',
|
||||
description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.',
|
||||
keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage',
|
||||
og: {
|
||||
title: 'ospab.host - Облачное S3 хранилище',
|
||||
description: 'S3-совместимое хранилище с поддержкой 24/7',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Cloud S3 Storage',
|
||||
description: 'ospab.host - reliable cloud S3-compatible storage in Veliky Novgorod. File storage, backups, media content. 24/7 support tickets, QR authentication.',
|
||||
keywords: 'hosting, cloud storage, S3, file storage, Veliky Novgorod, object storage',
|
||||
og: {
|
||||
title: 'ospab.host - Cloud S3 Storage',
|
||||
description: 'S3-compatible storage with 24/7 support',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/about': {
|
||||
title: 'О компании - Ospab Host',
|
||||
description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.',
|
||||
keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород',
|
||||
og: {
|
||||
title: 'О компании ospab.host',
|
||||
description: 'Современная платформа облачного хранилища',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/about',
|
||||
ru: {
|
||||
title: 'О компании - Ospab Host',
|
||||
description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.',
|
||||
keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород',
|
||||
og: {
|
||||
title: 'О компании ospab.host',
|
||||
description: 'Современная платформа облачного хранилища',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/about',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'About Company - Ospab Host',
|
||||
description: 'Learn about ospab.host - modern cloud storage platform in Veliky Novgorod. S3-compatible storage with support tickets. Founder Georgy Syralyov.',
|
||||
keywords: 'about ospab, hosting history, cloud solutions, S3 storage, Veliky Novgorod',
|
||||
og: {
|
||||
title: 'About ospab.host company',
|
||||
description: 'Modern cloud storage platform',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/about',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/login': {
|
||||
title: 'Вход в аккаунт - Ospab Host',
|
||||
description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.',
|
||||
keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления',
|
||||
og: {
|
||||
title: 'Вход в ospab.host',
|
||||
description: 'Доступ к панели управления',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/login',
|
||||
ru: {
|
||||
title: 'Вход в аккаунт - Ospab Host',
|
||||
description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.',
|
||||
keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления',
|
||||
og: {
|
||||
title: 'Вход в ospab.host',
|
||||
description: 'Доступ к панели управления',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/login',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Login to Account - Ospab Host',
|
||||
description: 'Log in to your ospab.host personal account. Manage storage, support tickets, QR authentication for quick login.',
|
||||
keywords: 'login account, personal account, ospab login, hosting login, QR login, control panel',
|
||||
og: {
|
||||
title: 'Login to ospab.host',
|
||||
description: 'Access to control panel',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/login',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/register': {
|
||||
title: 'Регистрация - Создать аккаунт',
|
||||
description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.',
|
||||
keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт',
|
||||
og: {
|
||||
title: 'Регистрация в ospab.host',
|
||||
description: 'Создайте аккаунт и начните использовать S3 хранилище',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/register',
|
||||
ru: {
|
||||
title: 'Регистрация - Создать аккаунт',
|
||||
description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.',
|
||||
keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт',
|
||||
og: {
|
||||
title: 'Регистрация в ospab.host',
|
||||
description: 'Создайте аккаунт и начните использовать S3 хранилище',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/register',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Registration - Create Account',
|
||||
description: 'Register with ospab.host and start using cloud storage. Create an account for free in 2 minutes, get access to S3 API and support tickets.',
|
||||
keywords: 'registration, create account, ospab registration, hosting registration, new account',
|
||||
og: {
|
||||
title: 'Registration at ospab.host',
|
||||
description: 'Create an account and start using S3 storage',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/register',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/blog': {
|
||||
title: 'Блог о хостинге и S3',
|
||||
description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.',
|
||||
keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage',
|
||||
og: {
|
||||
title: 'Блог ospab.host',
|
||||
description: 'Статьи о хостинге и DevOps',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/blog',
|
||||
ru: {
|
||||
title: 'Блог о хостинге и S3',
|
||||
description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.',
|
||||
keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage',
|
||||
og: {
|
||||
title: 'Блог ospab.host',
|
||||
description: 'Статьи о хостинге и DevOps',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/blog',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Blog about Hosting and S3',
|
||||
description: 'Articles about hosting, S3 storage, cloud technologies, DevOps practices, security. Useful guides from the ospab.host team.',
|
||||
keywords: 'hosting blog, S3 guides, cloud storage, DevOps, object storage',
|
||||
og: {
|
||||
title: 'ospab.host Blog',
|
||||
description: 'Articles about hosting and DevOps',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/blog',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/terms': {
|
||||
title: 'Условия использования',
|
||||
description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.',
|
||||
keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия',
|
||||
og: {
|
||||
title: 'Условия использования ospab.host',
|
||||
description: 'Полные условия использования сервиса',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/terms',
|
||||
ru: {
|
||||
title: 'Условия использования',
|
||||
description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.',
|
||||
keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия',
|
||||
og: {
|
||||
title: 'Условия использования ospab.host',
|
||||
description: 'Полные условия использования сервиса',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/terms',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Terms of Use',
|
||||
description: 'Terms of use for ospab.host service. Read the complete rules for cloud storage users.',
|
||||
keywords: 'terms of use, user agreement, usage rules, legal terms',
|
||||
og: {
|
||||
title: 'ospab.host Terms of Use',
|
||||
description: 'Complete terms of service',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/terms',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/privacy': {
|
||||
title: 'Политика конфиденциальности',
|
||||
description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.',
|
||||
keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных',
|
||||
og: {
|
||||
title: 'Политика конфиденциальности ospab.host',
|
||||
description: 'Защита ваших данных и приватности',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/privacy',
|
||||
ru: {
|
||||
title: 'Политика конфиденциальности',
|
||||
description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.',
|
||||
keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных',
|
||||
og: {
|
||||
title: 'Политика конфиденциальности ospab.host',
|
||||
description: 'Защита ваших данных и приватности',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/privacy',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Privacy Policy',
|
||||
description: 'ospab.host privacy policy. Learn how we protect your personal data, account information and payments. GDPR compliance.',
|
||||
keywords: 'privacy policy, privacy, data protection, GDPR, data security',
|
||||
og: {
|
||||
title: 'ospab.host Privacy Policy',
|
||||
description: 'Protection of your data and privacy',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/privacy',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -121,15 +227,21 @@ const SEO_CONFIG: Record<string, {
|
||||
// Компонент для обновления SEO при изменении маршрута
|
||||
function SEOUpdater() {
|
||||
const location = useLocation();
|
||||
const { locale } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
// Получаем SEO данные для текущего маршрута, иначе используем дефолтные
|
||||
const seoData = SEO_CONFIG[pathname] || {
|
||||
title: 'ospab.host - облачный хостинг',
|
||||
description: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.',
|
||||
keywords: 'хостинг, облачный хостинг, VPS, VDS',
|
||||
const seoConfig = SEO_CONFIG[pathname];
|
||||
const seoData = seoConfig ? seoConfig[locale] : {
|
||||
title: locale === 'en' ? 'ospab.host - cloud hosting' : 'ospab.host - облачный хостинг',
|
||||
description: locale === 'en'
|
||||
? 'ospab.host - reliable cloud hosting and virtual machines in Veliky Novgorod.'
|
||||
: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.',
|
||||
keywords: locale === 'en'
|
||||
? 'hosting, cloud hosting, VPS, VDS'
|
||||
: 'хостинг, облачный хостинг, VPS, VDS',
|
||||
};
|
||||
|
||||
// Устанавливаем title
|
||||
@@ -186,7 +298,7 @@ function SEOUpdater() {
|
||||
|
||||
// Скроллим вверх при навигации
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
}, [location.pathname, locale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -256,6 +368,15 @@ function App() {
|
||||
<Route path="/en/502" element={<BadGateway />} />
|
||||
<Route path="/en/503" element={<ServiceUnavailable />} />
|
||||
<Route path="/en/504" element={<GatewayTimeout />} />
|
||||
|
||||
{/* Cloudflare-style error page */}
|
||||
<Route path="/error" element={<ErrorPage />} />
|
||||
<Route path="/en/error" element={<ErrorPage />} />
|
||||
|
||||
{/* Network service page - default for unknown hosts */}
|
||||
<Route path="/1000" element={<NetworkError />} />
|
||||
<Route path="/en/1000" element={<NetworkError />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
interface ErrorPageProps {
|
||||
code: string;
|
||||
@@ -38,10 +40,13 @@ export default function ErrorPage({
|
||||
showBackButton = true,
|
||||
showHomeButton = true,
|
||||
}: ErrorPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white px-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
{/* Код ошибки */}
|
||||
{/* Error code */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-8xl font-bold text-gray-200 mb-4">{code}</h1>
|
||||
<div className={`inline-flex items-center justify-center w-16 h-16 rounded-full border-2 ${colorClasses[color]}`}>
|
||||
@@ -49,33 +54,33 @@ export default function ErrorPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Описание */}
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 mb-8">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Кнопки */}
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{showHomeButton && (
|
||||
<Link
|
||||
to="/"
|
||||
to={localePath('/')}
|
||||
className={`w-full inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white ${buttonColorClasses[color]} transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
>
|
||||
На главную
|
||||
{t('errors.goHome')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{showLoginButton && (
|
||||
<Link
|
||||
to="/login"
|
||||
to={localePath('/login')}
|
||||
className={`w-full inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white ${buttonColorClasses[color]} transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
>
|
||||
Войти
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -84,16 +89,16 @@ export default function ErrorPage({
|
||||
onClick={() => window.history.back()}
|
||||
className="w-full inline-flex items-center justify-center px-6 py-3 border-2 border-gray-300 text-base font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
Назад
|
||||
{t('common.back')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Контактная информация (опционально) */}
|
||||
{/* Contact info (optional) */}
|
||||
{(code === '500' || code === '503') && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
Если проблема сохраняется, свяжитесь с нами:{' '}
|
||||
{t('footer.contact')}:{' '}
|
||||
<a
|
||||
href="mailto:support@ospab.host"
|
||||
className={`${color === 'red' ? 'text-red-600' : color === 'orange' ? 'text-orange-600' : 'text-gray-600'} hover:underline font-medium`}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { getUnreadCount, getNotifications, markAsRead, type Notification } from '../services/notificationService';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { wsLogger } from '../utils/logger';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
const NotificationBell = () => {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
@@ -10,6 +11,8 @@ const NotificationBell = () => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { subscribe, unsubscribe, isConnected } = useWebSocket();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
// WebSocket обработчик событий
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -115,12 +118,19 @@ const NotificationBell = () => {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'только что';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`;
|
||||
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||
if (isEn) {
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} h ago`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} d ago`;
|
||||
return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' });
|
||||
} else {
|
||||
if (diffInSeconds < 60) return 'только что';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`;
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -129,7 +139,7 @@ const NotificationBell = () => {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
aria-label="Уведомления"
|
||||
aria-label={isEn ? 'Notifications' : 'Уведомления'}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
@@ -165,13 +175,13 @@ const NotificationBell = () => {
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-20 max-h-[600px] overflow-hidden flex flex-col">
|
||||
{/* Заголовок */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Уведомления</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-800">{isEn ? 'Notifications' : 'Уведомления'}</h3>
|
||||
<Link
|
||||
to="/dashboard/notifications"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-sm text-ospab-primary hover:underline"
|
||||
>
|
||||
Все
|
||||
{isEn ? 'All' : 'Все'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +196,7 @@ const NotificationBell = () => {
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p>Нет уведомлений</p>
|
||||
<p>{isEn ? 'No notifications' : 'Нет уведомлений'}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
@@ -240,7 +250,7 @@ const NotificationBell = () => {
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block w-full text-center py-2 text-sm text-ospab-primary hover:bg-gray-50 rounded-md transition-colors"
|
||||
>
|
||||
Показать все уведомления
|
||||
{isEn ? 'Show all notifications' : 'Показать все уведомления'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuth from '../context/useAuth';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface QRLoginProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -11,17 +12,22 @@ interface QRLoginProps {
|
||||
const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [qrCode, setQrCode] = useState<string>('');
|
||||
const [status, setStatus] = useState<'generating' | 'waiting' | 'scanning' | 'expired' | 'error'>('generating');
|
||||
const [pollingInterval, setPollingInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
||||
const [refreshInterval, setRefreshInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
||||
const [countdownInterval, setCountdownInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const [remaining, setRemaining] = useState<number>(0);
|
||||
const [requestInfo, setRequestInfo] = useState<{ ip?: string; ua?: string } | null>(null);
|
||||
const qrLinkBase = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
useEffect(() => {
|
||||
generateQR();
|
||||
return () => {
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -33,13 +39,30 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
setQrCode(response.data.code);
|
||||
setStatus('waiting');
|
||||
startPolling(response.data.code);
|
||||
|
||||
// Автоматическое обновление QR-кода каждые 60 секунд
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
const interval = setInterval(() => {
|
||||
generateQR();
|
||||
}, 60000);
|
||||
setRefreshInterval(interval);
|
||||
|
||||
// Устанавливаем время истечения и запускаем секундный таймер
|
||||
const expires = response.data.expiresAt ? new Date(response.data.expiresAt).getTime() : (Date.now() + (response.data.expiresIn || 180) * 1000);
|
||||
const initialRemaining = Math.max(0, Math.ceil((expires - Date.now()) / 1000));
|
||||
setRemaining(initialRemaining);
|
||||
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
const cd = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(cd);
|
||||
// expire QR
|
||||
setStatus('expired');
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
setPollingInterval(null);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
setCountdownInterval(cd);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка генерации QR:', error);
|
||||
setStatus('error');
|
||||
@@ -51,6 +74,16 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/qr-auth/status/${code}`);
|
||||
|
||||
// Update remaining if server provides it
|
||||
if (typeof response.data.expiresIn === 'number') {
|
||||
setRemaining(Math.max(0, Math.ceil(response.data.expiresIn)));
|
||||
}
|
||||
|
||||
// Update request info if provided
|
||||
if (response.data.ipAddress || response.data.userAgent) {
|
||||
setRequestInfo({ ip: response.data.ipAddress, ua: response.data.userAgent });
|
||||
}
|
||||
|
||||
// Если статус изменился на "scanning" (пользователь открыл страницу подтверждения)
|
||||
if (response.data.status === 'scanning') {
|
||||
setStatus('scanning');
|
||||
@@ -59,6 +92,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
if (response.data.status === 'confirmed' && response.data.token) {
|
||||
clearInterval(interval);
|
||||
setPollingInterval(null);
|
||||
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||
|
||||
// Вызываем login из контекста для обновления состояния
|
||||
login(response.data.token);
|
||||
@@ -71,6 +105,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
} else if (response.data.status === 'rejected') {
|
||||
clearInterval(interval);
|
||||
setPollingInterval(null);
|
||||
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||
setStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -78,7 +113,15 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
if (axiosError.response?.status === 404 || axiosError.response?.status === 410) {
|
||||
clearInterval(interval);
|
||||
setPollingInterval(null);
|
||||
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||
setStatus('expired');
|
||||
} else {
|
||||
// Для прочих ошибок (500 и т.д.) прекращаем пуллинг и показываем ошибку
|
||||
clearInterval(interval);
|
||||
setPollingInterval(null);
|
||||
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||
console.error('Ошибка при проверке статуса QR:', error);
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
}, 2000); // Проверка каждые 2 секунды
|
||||
@@ -89,15 +132,15 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
const getStatusMessage = () => {
|
||||
switch (status) {
|
||||
case 'generating':
|
||||
return 'Генерация...';
|
||||
return isEn ? 'Generating...' : 'Генерация...';
|
||||
case 'waiting':
|
||||
return 'Отсканируйте QR-код телефоном, на котором вы уже авторизованы';
|
||||
return isEn ? 'Scan the QR code with your phone where you are already logged in' : 'Отсканируйте QR-код телефоном, на котором вы уже авторизованы';
|
||||
case 'scanning':
|
||||
return 'Ожидание подтверждения на телефоне...';
|
||||
return isEn ? 'Waiting for confirmation on phone...' : 'Ожидание подтверждения на телефоне...';
|
||||
case 'expired':
|
||||
return 'QR-код истёк';
|
||||
return isEn ? 'QR code expired' : 'QR-код истёк';
|
||||
case 'error':
|
||||
return 'Ошибка';
|
||||
return isEn ? 'Error' : 'Ошибка';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -106,7 +149,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Вход по QR-коду</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">{isEn ? 'QR Code Login' : 'Вход по QR-коду'}</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{getStatusMessage()}
|
||||
</p>
|
||||
@@ -129,6 +172,23 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
includeMargin={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Countdown */}
|
||||
<div className="mt-3 text-sm text-gray-500 text-center">
|
||||
{remaining > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div>Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, '0')}</div>
|
||||
{requestInfo && (
|
||||
<div className="text-xs text-gray-400">
|
||||
<div>Device: {requestInfo.ua ?? '—'}</div>
|
||||
<div>IP: {requestInfo.ip ?? '—'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -139,7 +199,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
onClick={generateQR}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Обновить
|
||||
{isEn ? 'Refresh' : 'Обновить'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -151,7 +211,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
onClick={generateQR}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Попробовать снова
|
||||
{isEn ? 'Try again' : 'Попробовать снова'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -163,7 +223,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-blue-500 hover:text-blue-600 font-medium text-sm"
|
||||
>
|
||||
Войти по паролю
|
||||
{isEn ? 'Login with password' : 'Войти по паролю'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'recharts';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface ServerMetricsProps {
|
||||
serverId: number;
|
||||
@@ -58,6 +59,8 @@ interface Summary {
|
||||
}
|
||||
|
||||
export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [period, setPeriod] = useState<'1h' | '6h' | '24h' | '7d' | '30d'>('24h');
|
||||
const [history, setHistory] = useState<MetricData[]>([]);
|
||||
const [current, setCurrent] = useState<CurrentMetrics | null>(null);
|
||||
@@ -78,19 +81,26 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) return `${days}д ${hours}ч`;
|
||||
if (hours > 0) return `${hours}ч ${minutes}м`;
|
||||
return `${minutes}м`;
|
||||
if (isEn) {
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
} else {
|
||||
if (days > 0) return `${days}д ${hours}ч`;
|
||||
if (hours > 0) return `${hours}ч ${minutes}м`;
|
||||
return `${minutes}м`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const localeStr = isEn ? 'en-US' : 'ru-RU';
|
||||
if (period === '1h' || period === '6h') {
|
||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString(localeStr, { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (period === '24h') {
|
||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString(localeStr, { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric' });
|
||||
return date.toLocaleDateString(localeStr, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +111,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
throw new Error('Токен не найден. Пожалуйста, войдите снова.');
|
||||
throw new Error(isEn ? 'Token not found. Please log in again.' : 'Токен не найден. Пожалуйста, войдите снова.');
|
||||
}
|
||||
|
||||
// Получаем текущие метрики
|
||||
@@ -130,11 +140,11 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
const error = err as { response?: { status?: number; data?: { error?: string } }; message?: string };
|
||||
console.error('❌ Ошибка загрузки метрик:', error);
|
||||
if (error.response?.status === 401) {
|
||||
setError('Ошибка авторизации. Пожалуйста, войдите снова.');
|
||||
setError(isEn ? 'Authorization error. Please log in again.' : 'Ошибка авторизации. Пожалуйста, войдите снова.');
|
||||
// Можно добавить редирект на логин
|
||||
// window.location.href = '/login';
|
||||
} else {
|
||||
setError(error.response?.data?.error || error.message || 'Ошибка загрузки метрик');
|
||||
setError(error.response?.data?.error || error.message || (isEn ? 'Error loading metrics' : 'Ошибка загрузки метрик'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -152,7 +162,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
if (loading && !current) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Загрузка метрик...</div>
|
||||
<div className="text-gray-500">{isEn ? 'Loading metrics...' : 'Загрузка метрик...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +175,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
onClick={fetchMetrics}
|
||||
className="mt-2 text-sm text-red-600 hover:text-red-800 underline"
|
||||
>
|
||||
Попробовать снова
|
||||
{isEn ? 'Try again' : 'Попробовать снова'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -193,7 +203,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Ср: {summary.cpu.avg.toFixed(1)}% | Макс: {summary.cpu.max.toFixed(1)}%
|
||||
{isEn ? 'Avg' : 'Ср'}: {summary.cpu.avg.toFixed(1)}% | {isEn ? 'Max' : 'Макс'}: {summary.cpu.max.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,7 +211,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
{/* Memory */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">Память</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">{isEn ? 'Memory' : 'Память'}</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{current.memory.usage.toFixed(1)}%
|
||||
@@ -211,7 +221,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Ср: {summary.memory.avg.toFixed(1)}%
|
||||
{isEn ? 'Avg' : 'Ср'}: {summary.memory.avg.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,7 +229,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
{/* Disk */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">Диск</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">{isEn ? 'Disk' : 'Диск'}</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{current.disk.usage.toFixed(1)}%
|
||||
@@ -229,7 +239,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Ср: {summary.disk.avg.toFixed(1)}%
|
||||
{isEn ? 'Avg' : 'Ср'}: {summary.disk.avg.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +247,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
{/* Network */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">Сеть</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">{isEn ? 'Network' : 'Сеть'}</h3>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
↓ {formatBytes(current.network.in)}
|
||||
@@ -254,7 +264,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
{/* Фильтр периода */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-gray-700">Период:</span>
|
||||
<span className="text-sm font-medium text-gray-700">{isEn ? 'Period:' : 'Период:'}</span>
|
||||
{(['1h', '6h', '24h', '7d', '30d'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
@@ -265,7 +275,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{p === '1h' ? '1 час' : p === '6h' ? '6 часов' : p === '24h' ? '24 часа' : p === '7d' ? '7 дней' : '30 дней'}
|
||||
{isEn ? (p === '1h' ? '1 hour' : p === '6h' ? '6 hours' : p === '24h' ? '24 hours' : p === '7d' ? '7 days' : '30 days') : (p === '1h' ? '1 час' : p === '6h' ? '6 часов' : p === '24h' ? '24 часа' : p === '7d' ? '7 дней' : '30 дней')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -275,7 +285,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
<div className="space-y-6">
|
||||
{/* CPU График */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Использование CPU</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{isEn ? 'CPU Usage' : 'Использование CPU'}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={history}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -306,7 +316,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
{/* Memory и Disk */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Использование памяти и диска</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{isEn ? 'Memory and Disk Usage' : 'Использование памяти и диска'}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={history}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -329,14 +339,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
type="monotone"
|
||||
dataKey="memoryUsage"
|
||||
stroke="#3B82F6"
|
||||
name="Память"
|
||||
name={isEn ? 'Memory' : 'Память'}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="diskUsage"
|
||||
stroke="#10B981"
|
||||
name="Диск"
|
||||
name={isEn ? 'Disk' : 'Диск'}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
@@ -345,7 +355,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
{/* Network Traffic */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Сетевой трафик</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{isEn ? 'Network Traffic' : 'Сетевой трафик'}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={history}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -368,14 +378,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
dataKey="networkIn"
|
||||
stroke="#8B5CF6"
|
||||
fill="#C4B5FD"
|
||||
name="Входящий"
|
||||
name={isEn ? 'Incoming' : 'Входящий'}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="networkOut"
|
||||
stroke="#EC4899"
|
||||
fill="#F9A8D4"
|
||||
name="Исходящий"
|
||||
name={isEn ? 'Outgoing' : 'Исходящий'}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -385,18 +395,18 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
{loading ? 'Загрузка данных...' : 'Нет данных за выбранный период'}
|
||||
{loading ? (isEn ? 'Loading data...' : 'Загрузка данных...') : (isEn ? 'No data for selected period' : 'Нет данных за выбранный период')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{current ? 'Метрики собираются автоматически каждую минуту' : 'Данные появятся через 1-2 минуты после запуска сервера'}
|
||||
{current ? (isEn ? 'Metrics are collected automatically every minute' : 'Метрики собираются автоматически каждую минуту') : (isEn ? 'Data will appear 1-2 minutes after server start' : 'Данные появятся через 1-2 минуты после запуска сервера')}
|
||||
</p>
|
||||
{current && (
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200 max-w-md mx-auto">
|
||||
<p className="text-sm text-blue-800 font-medium mb-2">💡 Хотите увидеть графики?</p>
|
||||
<p className="text-sm text-blue-800 font-medium mb-2">{isEn ? '💡 Want to see charts?' : '💡 Хотите увидеть графики?'}</p>
|
||||
<p className="text-xs text-blue-700">
|
||||
1. Откройте консоль сервера<br/>
|
||||
2. Запустите: <code className="bg-blue-100 px-2 py-1 rounded">stress-ng --cpu 2 --cpu-load 50 --timeout 180s</code><br/>
|
||||
3. Обновите страницу через 1-2 минуты
|
||||
{isEn ? '1. Open server console' : '1. Откройте консоль сервера'}<br/>
|
||||
{isEn ? '2. Run: ' : '2. Запустите: '}<code className="bg-blue-100 px-2 py-1 rounded">stress-ng --cpu 2 --cpu-load 50 --timeout 180s</code><br/>
|
||||
{isEn ? '3. Refresh page in 1-2 minutes' : '3. Обновите страницу через 1-2 минуты'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -404,7 +414,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
onClick={fetchMetrics}
|
||||
className="mt-6 px-6 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||
>
|
||||
🔄 Обновить данные
|
||||
{isEn ? '🔄 Refresh data' : '🔄 Обновить данные'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -113,7 +113,7 @@ const ToastItem: React.FC<ToastItemProps> = ({ toast, onClose, index }) => {
|
||||
animation: `toast-enter 0.3s ease-out ${index * 0.1}s both`
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px]`}>
|
||||
<div className={`${styles.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-sm`}>
|
||||
<div className="flex-shrink-0">
|
||||
{styles.icon}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaGithub } from 'react-icons/fa';
|
||||
import logo from '../assets/logo.svg';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-800 text-white py-12">
|
||||
@@ -12,32 +16,32 @@ const Footer = () => {
|
||||
{/* About Section */}
|
||||
<div>
|
||||
<div className="mb-4 flex justify-center md:justify-start">
|
||||
<img src={logo} alt="Логотип" className="h-16 w-auto" width="64" height="64" />
|
||||
<img src={logo} alt="Logo" className="h-20 w-20 rounded-full bg-white p-1.5 shadow-lg" width="80" height="80" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4">О нас</h3>
|
||||
<h3 className="text-xl font-bold mb-4">{t('nav.about')}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
ospab.host - это надежный хостинг для ваших проектов. Мы предлагаем высокую производительность и круглосуточную поддержку.
|
||||
{t('footer.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-4">Навигация</h3>
|
||||
<h3 className="text-xl font-bold mb-4">{t('footer.links')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/" className="text-gray-400 hover:text-white transition-colors">Главная</Link></li>
|
||||
<li><Link to="/tariffs" className="text-gray-400 hover:text-white transition-colors">Тарифы</Link></li>
|
||||
<li><Link to="/about" className="text-gray-400 hover:text-white transition-colors">О нас</Link></li>
|
||||
<li><Link to="/blog" className="text-gray-400 hover:text-white transition-colors">Блог</Link></li>
|
||||
<li><Link to="/login" className="text-gray-400 hover:text-white transition-colors">Войти</Link></li>
|
||||
<li><Link to={localePath('/')} className="text-gray-400 hover:text-white transition-colors">{t('nav.home')}</Link></li>
|
||||
<li><Link to={localePath('/tariffs')} className="text-gray-400 hover:text-white transition-colors">{t('nav.tariffs')}</Link></li>
|
||||
<li><Link to={localePath('/about')} className="text-gray-400 hover:text-white transition-colors">{t('nav.about')}</Link></li>
|
||||
<li><Link to={localePath('/blog')} className="text-gray-400 hover:text-white transition-colors">{t('nav.blog')}</Link></li>
|
||||
<li><Link to={localePath('/login')} className="text-gray-400 hover:text-white transition-colors">{t('nav.login')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal Documents */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-4">Документы</h3>
|
||||
<h3 className="text-xl font-bold mb-4">{t('footer.legal')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/privacy" className="text-gray-400 hover:text-white transition-colors">Политика конфиденциальности</Link></li>
|
||||
<li><Link to="/terms" className="text-gray-400 hover:text-white transition-colors">Условия использования</Link></li>
|
||||
<li><Link to={localePath('/privacy')} className="text-gray-400 hover:text-white transition-colors">{t('footer.privacy')}</Link></li>
|
||||
<li><Link to={localePath('/terms')} className="text-gray-400 hover:text-white transition-colors">{t('footer.terms')}</Link></li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/ospab/ospabhost8.1"
|
||||
@@ -53,10 +57,34 @@ const Footer = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-700 text-center">
|
||||
<div className="mt-8 pt-8 border-t border-gray-700 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
© {currentYear} ospab.host. Все права защищены.
|
||||
© {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
|
||||
</p>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setLocale('ru')}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
locale === 'ru'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocale('en')}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
locale === 'en'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,10 +3,14 @@ import { useState } from 'react';
|
||||
import useAuth from '../context/useAuth';
|
||||
import logo from '../assets/logo.svg';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const Header = () => {
|
||||
const { isLoggedIn, logout } = useAuth();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -18,36 +22,36 @@ const Header = () => {
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link to="/" className="flex items-center">
|
||||
<img src={logo} alt="Логотип" className="h-10 lg:h-14 w-auto mr-2" width="56" height="56" />
|
||||
<Link to={localePath('/')} className="flex items-center">
|
||||
<img src={logo} alt="Logo" className="h-10 lg:h-14 w-auto mr-2" width="56" height="56" />
|
||||
<span className="font-mono text-xl lg:text-2xl text-gray-800 font-bold">ospab.host</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<Link to="/tariffs" className="text-gray-600 hover:text-ospab-primary transition-colors">Тарифы</Link>
|
||||
<Link to="/blog" className="text-gray-600 hover:text-ospab-primary transition-colors">Блог</Link>
|
||||
<Link to="/about" className="text-gray-600 hover:text-ospab-primary transition-colors">О нас</Link>
|
||||
<Link to={localePath('/tariffs')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.tariffs')}</Link>
|
||||
<Link to={localePath('/blog')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.blog')}</Link>
|
||||
<Link to={localePath('/about')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.about')}</Link>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="text-gray-600 hover:text-ospab-primary transition-colors">Личный кабинет</Link>
|
||||
<Link to={localePath('/dashboard')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.dashboard')}</Link>
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-red-500"
|
||||
>
|
||||
Выйти
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="text-gray-600 hover:text-ospab-primary transition-colors">Войти</Link>
|
||||
<Link to={localePath('/login')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.login')}</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
to={localePath('/register')}
|
||||
className="px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent"
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -57,7 +61,7 @@ const Header = () => {
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="md:hidden p-2 text-gray-800"
|
||||
aria-label={isMobileMenuOpen ? "Закрыть меню" : "Открыть меню"}
|
||||
aria-label={isMobileMenuOpen ? t('common.closeMenu') : t('common.openMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -74,57 +78,57 @@ const Header = () => {
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden mt-4 pb-4 space-y-2 border-t border-gray-200 pt-4">
|
||||
<Link
|
||||
to="/tariffs"
|
||||
to={localePath('/tariffs')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Тарифы
|
||||
{t('nav.tariffs')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
to={localePath('/blog')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Блог
|
||||
{t('nav.blog')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
to={localePath('/about')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
О нас
|
||||
{t('nav.about')}
|
||||
</Link>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
to={localePath('/dashboard')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Личный кабинет
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left py-2 text-gray-600 hover:text-red-500 transition-colors"
|
||||
>
|
||||
Выйти
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
to={localePath('/login')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Войти
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
to={localePath('/register')}
|
||||
className="block w-full text-center mt-2 px-4 py-2 rounded-full text-white font-bold bg-ospab-primary hover:bg-ospab-accent"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
3
ospabhost/frontend/src/i18n/index.ts
Normal file
3
ospabhost/frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useTranslation, getTranslation } from './useTranslation';
|
||||
export type { TranslationKey, TranslationKeys } from './useTranslation';
|
||||
export { ru, en } from './translations';
|
||||
342
ospabhost/frontend/src/i18n/translations/en.ts
Normal file
342
ospabhost/frontend/src/i18n/translations/en.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import type { TranslationKeys } from './ru';
|
||||
|
||||
export const en: TranslationKeys = {
|
||||
// Navigation
|
||||
nav: {
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
tariffs: 'Pricing',
|
||||
blog: 'Blog',
|
||||
login: 'Sign In',
|
||||
register: 'Sign Up',
|
||||
dashboard: 'Dashboard',
|
||||
logout: 'Sign Out',
|
||||
},
|
||||
|
||||
// Home page
|
||||
home: {
|
||||
hero: {
|
||||
title: 'Cloud S3 Storage',
|
||||
subtitle: 'Reliable S3-compatible storage in Veliky Novgorod',
|
||||
description: 'Store files, backups and media content. 24/7 support, flexible plans.',
|
||||
cta: 'Get Started Free',
|
||||
learnMore: 'Learn More',
|
||||
},
|
||||
features: {
|
||||
title: 'Why Choose Us',
|
||||
s3Compatible: {
|
||||
title: 'S3 Compatible',
|
||||
description: 'Fully compatible with AWS S3 API. Use your favorite tools.',
|
||||
},
|
||||
reliability: {
|
||||
title: 'Reliability',
|
||||
description: 'Data replication, backups and 24/7 monitoring.',
|
||||
},
|
||||
speed: {
|
||||
title: 'Speed',
|
||||
description: 'Fast SSD drives and optimized network.',
|
||||
},
|
||||
support: {
|
||||
title: 'Support',
|
||||
description: 'Quick response support tickets. Help available in English.',
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
title: 'Pricing',
|
||||
subtitle: 'Choose the right plan',
|
||||
perMonth: '/month',
|
||||
perGb: 'per GB',
|
||||
storage: 'Storage',
|
||||
traffic: 'Traffic',
|
||||
support: 'Support',
|
||||
selectPlan: 'Select',
|
||||
},
|
||||
cta: {
|
||||
title: 'Ready to get started?',
|
||||
description: 'Join developers who trust us with their data.',
|
||||
},
|
||||
},
|
||||
|
||||
// About page
|
||||
about: {
|
||||
title: 'About Us',
|
||||
subtitle: 'Ospab.host — modern cloud storage platform',
|
||||
story: {
|
||||
title: 'Our Story',
|
||||
text: 'We created ospab.host to provide reliable and affordable cloud storage for businesses and developers.',
|
||||
},
|
||||
team: {
|
||||
title: 'Our Team',
|
||||
founder: 'Founder',
|
||||
},
|
||||
location: {
|
||||
title: 'Location',
|
||||
text: 'Our servers are located in Veliky Novgorod, Russia.',
|
||||
},
|
||||
},
|
||||
|
||||
// Authentication
|
||||
auth: {
|
||||
login: {
|
||||
title: 'Sign In',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
submit: 'Sign In',
|
||||
noAccount: "Don't have an account?",
|
||||
register: 'Sign Up',
|
||||
forgotPassword: 'Forgot password?',
|
||||
orContinueWith: 'or continue with',
|
||||
},
|
||||
register: {
|
||||
title: 'Sign Up',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'Username',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'Email address',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
submit: 'Sign Up',
|
||||
loading: 'Signing up...',
|
||||
hasAccount: 'Already have an account?',
|
||||
haveAccount: 'Already have an account?',
|
||||
login: 'Sign In',
|
||||
loginLink: 'Sign In',
|
||||
orRegisterWith: 'Or sign up with',
|
||||
terms: 'By signing up, you agree to our',
|
||||
termsLink: 'Terms of Service',
|
||||
and: 'and',
|
||||
privacyLink: 'Privacy Policy',
|
||||
success: 'Registration successful! You can now sign in.',
|
||||
captchaRequired: 'Please confirm that you are not a robot.',
|
||||
captchaError: 'Captcha loading error. Please refresh the page.',
|
||||
unknownError: 'Unknown registration error.',
|
||||
networkError: 'Network error. Please try again later.',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
// Email validation
|
||||
emailValidation: {
|
||||
invalidFormat: 'Invalid email format',
|
||||
disposableEmail: 'Disposable email addresses are not allowed',
|
||||
suggestion: 'Did you mean: {email}?',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
invalidCredentials: 'Invalid email or password',
|
||||
emailRequired: 'Email is required',
|
||||
passwordRequired: 'Password is required',
|
||||
passwordTooShort: 'Password must be at least 6 characters',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
usernameRequired: 'Username is required',
|
||||
emailInvalid: 'Invalid email address',
|
||||
},
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
welcome: 'Welcome',
|
||||
sidebar: {
|
||||
overview: 'Overview',
|
||||
storage: 'Storage',
|
||||
buckets: 'Buckets',
|
||||
tickets: 'Tickets',
|
||||
billing: 'Billing',
|
||||
settings: 'Settings',
|
||||
admin: 'Admin',
|
||||
},
|
||||
overview: {
|
||||
balance: 'Balance',
|
||||
storage: 'Used',
|
||||
buckets: 'Buckets',
|
||||
tickets: 'Open Tickets',
|
||||
},
|
||||
storage: {
|
||||
title: 'Storage',
|
||||
createBucket: 'Create Bucket',
|
||||
bucketName: 'Bucket Name',
|
||||
bucketNamePlaceholder: 'my-bucket',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
empty: 'You have no buckets yet',
|
||||
emptyDescription: 'Create your first bucket to store files',
|
||||
},
|
||||
bucket: {
|
||||
files: 'Files',
|
||||
upload: 'Upload',
|
||||
uploadFiles: 'Upload Files',
|
||||
uploadFolder: 'Upload Folder',
|
||||
uploadFromUrl: 'Upload from URL',
|
||||
createFolder: 'Create Folder',
|
||||
delete: 'Delete',
|
||||
download: 'Download',
|
||||
rename: 'Rename',
|
||||
copy: 'Copy',
|
||||
move: 'Move',
|
||||
share: 'Share',
|
||||
properties: 'Properties',
|
||||
emptyBucket: 'Bucket is empty',
|
||||
emptyBucketDescription: 'Upload files or create a folder',
|
||||
dropFilesHere: 'Drop files here',
|
||||
orClickToUpload: 'or click to upload',
|
||||
accessKey: 'Access Key',
|
||||
secretKey: 'Secret Key',
|
||||
endpoint: 'Endpoint',
|
||||
region: 'Region',
|
||||
},
|
||||
tickets: {
|
||||
title: 'Tickets',
|
||||
create: 'Create Ticket',
|
||||
subject: 'Subject',
|
||||
message: 'Message',
|
||||
priority: 'Priority',
|
||||
status: 'Status',
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
open: 'Open',
|
||||
closed: 'Closed',
|
||||
pending: 'Pending',
|
||||
inProgress: 'In Progress',
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
urgent: 'Urgent',
|
||||
noTickets: 'You have no tickets',
|
||||
noTicketsDescription: 'Create a ticket if you need help',
|
||||
reply: 'Reply',
|
||||
close: 'Close Ticket',
|
||||
reopen: 'Reopen',
|
||||
},
|
||||
billing: {
|
||||
title: 'Billing',
|
||||
balance: 'Balance',
|
||||
topUp: 'Top Up',
|
||||
history: 'Transaction History',
|
||||
date: 'Date',
|
||||
description: 'Description',
|
||||
amount: 'Amount',
|
||||
noTransactions: 'No transactions',
|
||||
uploadCheck: 'Upload Receipt',
|
||||
checkPending: 'Pending Review',
|
||||
checkApproved: 'Approved',
|
||||
checkRejected: 'Rejected',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
profile: 'Profile',
|
||||
security: 'Security',
|
||||
notifications: 'Notifications',
|
||||
appearance: 'Appearance',
|
||||
language: 'Language',
|
||||
timezone: 'Timezone',
|
||||
save: 'Save',
|
||||
changePassword: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
confirmNewPassword: 'Confirm New Password',
|
||||
deleteAccount: 'Delete Account',
|
||||
deleteAccountWarning: 'This action is irreversible. All your data will be deleted.',
|
||||
},
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
error: 'Error',
|
||||
success: 'Success',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
create: 'Create',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
search: 'Search',
|
||||
filter: 'Filter',
|
||||
sort: 'Sort',
|
||||
refresh: 'Refresh',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
or: 'or',
|
||||
and: 'and',
|
||||
of: 'of',
|
||||
items: 'items',
|
||||
bytes: 'bytes',
|
||||
kb: 'KB',
|
||||
mb: 'MB',
|
||||
gb: 'GB',
|
||||
tb: 'TB',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
notFound: 'Page Not Found',
|
||||
notFoundDescription: 'The page you are looking for does not exist or has been removed.',
|
||||
unauthorized: 'Unauthorized',
|
||||
unauthorizedDescription: 'You need to sign in to access this page.',
|
||||
forbidden: 'Access Denied',
|
||||
forbiddenDescription: 'You do not have permission to view this page.',
|
||||
serverError: 'Server Error',
|
||||
serverErrorDescription: 'An internal server error occurred. Please try again later.',
|
||||
badGateway: 'Bad Gateway',
|
||||
badGatewayDescription: 'The server is temporarily unavailable. Please try again later.',
|
||||
serviceUnavailable: 'Service Unavailable',
|
||||
serviceUnavailableDescription: 'The service is temporarily unavailable. Maintenance in progress.',
|
||||
gatewayTimeout: 'Gateway Timeout',
|
||||
gatewayTimeoutDescription: 'The server did not respond in time. Please try again later.',
|
||||
goHome: 'Go Home',
|
||||
tryAgain: 'Try Again',
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
description: 'Reliable cloud S3 storage',
|
||||
links: 'Links',
|
||||
legal: 'Legal',
|
||||
terms: 'Terms of Service',
|
||||
privacy: 'Privacy Policy',
|
||||
contact: 'Contact',
|
||||
copyright: '© 2024 ospab.host. All rights reserved.',
|
||||
},
|
||||
|
||||
// Blog
|
||||
blog: {
|
||||
title: 'Blog',
|
||||
subtitle: 'Articles about hosting, S3 and cloud technologies',
|
||||
readMore: 'Read More',
|
||||
published: 'Published',
|
||||
author: 'Author',
|
||||
tags: 'Tags',
|
||||
relatedPosts: 'Related Posts',
|
||||
noPosts: 'No posts yet',
|
||||
},
|
||||
|
||||
// Tariffs
|
||||
tariffs: {
|
||||
title: 'S3 Storage Pricing',
|
||||
subtitle: 'Choose the right plan for your needs',
|
||||
popular: 'Popular',
|
||||
features: 'Features',
|
||||
storage: 'Storage',
|
||||
traffic: 'Outbound Traffic',
|
||||
requests: 'Requests',
|
||||
support: 'Support',
|
||||
api: 'S3 API',
|
||||
included: 'Included',
|
||||
unlimited: 'Unlimited',
|
||||
perMonth: '/month',
|
||||
selectPlan: 'Select Plan',
|
||||
currentPlan: 'Current Plan',
|
||||
contactUs: 'Contact Us',
|
||||
customPlan: 'Need a custom plan?',
|
||||
customPlanDescription: 'Contact us to discuss special requirements.',
|
||||
},
|
||||
};
|
||||
2
ospabhost/frontend/src/i18n/translations/index.ts
Normal file
2
ospabhost/frontend/src/i18n/translations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ru, type TranslationKeys } from './ru';
|
||||
export { en } from './en';
|
||||
342
ospabhost/frontend/src/i18n/translations/ru.ts
Normal file
342
ospabhost/frontend/src/i18n/translations/ru.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
export const ru = {
|
||||
// Навигация
|
||||
nav: {
|
||||
home: 'Главная',
|
||||
about: 'О нас',
|
||||
tariffs: 'Тарифы',
|
||||
blog: 'Блог',
|
||||
login: 'Войти',
|
||||
register: 'Регистрация',
|
||||
dashboard: 'Панель управления',
|
||||
logout: 'Выйти',
|
||||
},
|
||||
|
||||
// Главная страница
|
||||
home: {
|
||||
hero: {
|
||||
title: 'Облачное S3 хранилище',
|
||||
subtitle: 'Надёжное S3-совместимое хранилище в Великом Новгороде',
|
||||
description: 'Храните файлы, резервные копии и медиа-контент. Поддержка 24/7, гибкие тарифы.',
|
||||
cta: 'Начать бесплатно',
|
||||
learnMore: 'Узнать больше',
|
||||
},
|
||||
features: {
|
||||
title: 'Почему выбирают нас',
|
||||
s3Compatible: {
|
||||
title: 'S3-совместимость',
|
||||
description: 'Полная совместимость с AWS S3 API. Используйте привычные инструменты.',
|
||||
},
|
||||
reliability: {
|
||||
title: 'Надёжность',
|
||||
description: 'Репликация данных, резервное копирование и мониторинг 24/7.',
|
||||
},
|
||||
speed: {
|
||||
title: 'Скорость',
|
||||
description: 'Быстрые SSD-накопители и оптимизированная сеть.',
|
||||
},
|
||||
support: {
|
||||
title: 'Поддержка',
|
||||
description: 'Тикеты поддержки с быстрым откликом. Помощь на русском языке.',
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
title: 'Тарифы',
|
||||
subtitle: 'Выберите подходящий план',
|
||||
perMonth: '/месяц',
|
||||
perGb: 'за ГБ',
|
||||
storage: 'Хранилище',
|
||||
traffic: 'Трафик',
|
||||
support: 'Поддержка',
|
||||
selectPlan: 'Выбрать',
|
||||
},
|
||||
cta: {
|
||||
title: 'Готовы начать?',
|
||||
description: 'Присоединяйтесь к разработчикам, которые доверяют нам свои данные.',
|
||||
},
|
||||
},
|
||||
|
||||
// Страница о нас
|
||||
about: {
|
||||
title: 'О компании',
|
||||
subtitle: 'Ospab.host — современная платформа облачного хранилища',
|
||||
story: {
|
||||
title: 'Наша история',
|
||||
text: 'Мы создали ospab.host чтобы предоставить надёжное и доступное облачное хранилище для бизнеса и разработчиков.',
|
||||
},
|
||||
team: {
|
||||
title: 'Наша команда',
|
||||
founder: 'Основатель',
|
||||
},
|
||||
location: {
|
||||
title: 'Расположение',
|
||||
text: 'Наши серверы расположены в Великом Новгороде, Россия.',
|
||||
},
|
||||
},
|
||||
|
||||
// Авторизация
|
||||
auth: {
|
||||
login: {
|
||||
title: 'Вход в аккаунт',
|
||||
email: 'Email',
|
||||
password: 'Пароль',
|
||||
submit: 'Войти',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
register: 'Зарегистрироваться',
|
||||
forgotPassword: 'Забыли пароль?',
|
||||
orContinueWith: 'или продолжить с',
|
||||
},
|
||||
register: {
|
||||
title: 'Регистрация',
|
||||
username: 'Имя пользователя',
|
||||
usernamePlaceholder: 'Имя пользователя',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'Электронная почта',
|
||||
password: 'Пароль',
|
||||
passwordPlaceholder: 'Пароль',
|
||||
confirmPassword: 'Подтвердите пароль',
|
||||
submit: 'Зарегистрироваться',
|
||||
loading: 'Регистрируем...',
|
||||
hasAccount: 'Уже есть аккаунт?',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
login: 'Войти',
|
||||
loginLink: 'Войти',
|
||||
orRegisterWith: 'Или зарегистрироваться через',
|
||||
terms: 'Регистрируясь, вы соглашаетесь с',
|
||||
termsLink: 'условиями использования',
|
||||
and: 'и',
|
||||
privacyLink: 'политикой конфиденциальности',
|
||||
success: 'Регистрация прошла успешно! Теперь вы можете войти.',
|
||||
captchaRequired: 'Пожалуйста, подтвердите, что вы не робот.',
|
||||
captchaError: 'Ошибка загрузки капчи. Попробуйте обновить страницу.',
|
||||
unknownError: 'Неизвестная ошибка регистрации.',
|
||||
networkError: 'Произошла ошибка сети. Пожалуйста, попробуйте позже.',
|
||||
invalidEmail: 'Введите корректный email адрес',
|
||||
// Email validation
|
||||
emailValidation: {
|
||||
invalidFormat: 'Неверный формат email адреса',
|
||||
disposableEmail: 'Временные email адреса не допускаются',
|
||||
suggestion: 'Возможно, вы имели в виду: {email}?',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
invalidCredentials: 'Неверный email или пароль',
|
||||
emailRequired: 'Email обязателен',
|
||||
passwordRequired: 'Пароль обязателен',
|
||||
passwordTooShort: 'Пароль должен быть минимум 6 символов',
|
||||
passwordsDoNotMatch: 'Пароли не совпадают',
|
||||
usernameRequired: 'Имя пользователя обязательно',
|
||||
emailInvalid: 'Некорректный email',
|
||||
},
|
||||
},
|
||||
|
||||
// Дашборд
|
||||
dashboard: {
|
||||
title: 'Панель управления',
|
||||
welcome: 'Добро пожаловать',
|
||||
sidebar: {
|
||||
overview: 'Обзор',
|
||||
storage: 'Хранилище',
|
||||
buckets: 'Бакеты',
|
||||
tickets: 'Тикеты',
|
||||
billing: 'Биллинг',
|
||||
settings: 'Настройки',
|
||||
admin: 'Админ',
|
||||
},
|
||||
overview: {
|
||||
balance: 'Баланс',
|
||||
storage: 'Использовано',
|
||||
buckets: 'Бакетов',
|
||||
tickets: 'Открытых тикетов',
|
||||
},
|
||||
storage: {
|
||||
title: 'Хранилище',
|
||||
createBucket: 'Создать бакет',
|
||||
bucketName: 'Название бакета',
|
||||
bucketNamePlaceholder: 'my-bucket',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
empty: 'У вас пока нет бакетов',
|
||||
emptyDescription: 'Создайте первый бакет для хранения файлов',
|
||||
},
|
||||
bucket: {
|
||||
files: 'Файлы',
|
||||
upload: 'Загрузить',
|
||||
uploadFiles: 'Загрузить файлы',
|
||||
uploadFolder: 'Загрузить папку',
|
||||
uploadFromUrl: 'Загрузить по URL',
|
||||
createFolder: 'Создать папку',
|
||||
delete: 'Удалить',
|
||||
download: 'Скачать',
|
||||
rename: 'Переименовать',
|
||||
copy: 'Копировать',
|
||||
move: 'Переместить',
|
||||
share: 'Поделиться',
|
||||
properties: 'Свойства',
|
||||
emptyBucket: 'Бакет пуст',
|
||||
emptyBucketDescription: 'Загрузите файлы или создайте папку',
|
||||
dropFilesHere: 'Перетащите файлы сюда',
|
||||
orClickToUpload: 'или нажмите для выбора',
|
||||
accessKey: 'Ключ доступа',
|
||||
secretKey: 'Секретный ключ',
|
||||
endpoint: 'Endpoint',
|
||||
region: 'Регион',
|
||||
},
|
||||
tickets: {
|
||||
title: 'Тикеты',
|
||||
create: 'Создать тикет',
|
||||
subject: 'Тема',
|
||||
message: 'Сообщение',
|
||||
priority: 'Приоритет',
|
||||
status: 'Статус',
|
||||
created: 'Создан',
|
||||
updated: 'Обновлён',
|
||||
open: 'Открыт',
|
||||
closed: 'Закрыт',
|
||||
pending: 'В ожидании',
|
||||
inProgress: 'В работе',
|
||||
low: 'Низкий',
|
||||
medium: 'Средний',
|
||||
high: 'Высокий',
|
||||
urgent: 'Срочный',
|
||||
noTickets: 'У вас нет тикетов',
|
||||
noTicketsDescription: 'Создайте тикет если вам нужна помощь',
|
||||
reply: 'Ответить',
|
||||
close: 'Закрыть тикет',
|
||||
reopen: 'Открыть заново',
|
||||
},
|
||||
billing: {
|
||||
title: 'Биллинг',
|
||||
balance: 'Баланс',
|
||||
topUp: 'Пополнить',
|
||||
history: 'История операций',
|
||||
date: 'Дата',
|
||||
description: 'Описание',
|
||||
amount: 'Сумма',
|
||||
noTransactions: 'Нет транзакций',
|
||||
uploadCheck: 'Загрузить чек',
|
||||
checkPending: 'На проверке',
|
||||
checkApproved: 'Одобрен',
|
||||
checkRejected: 'Отклонён',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
profile: 'Профиль',
|
||||
security: 'Безопасность',
|
||||
notifications: 'Уведомления',
|
||||
appearance: 'Внешний вид',
|
||||
language: 'Язык',
|
||||
timezone: 'Часовой пояс',
|
||||
save: 'Сохранить',
|
||||
changePassword: 'Сменить пароль',
|
||||
currentPassword: 'Текущий пароль',
|
||||
newPassword: 'Новый пароль',
|
||||
confirmNewPassword: 'Подтвердите новый пароль',
|
||||
deleteAccount: 'Удалить аккаунт',
|
||||
deleteAccountWarning: 'Это действие необратимо. Все ваши данные будут удалены.',
|
||||
},
|
||||
},
|
||||
|
||||
// Общие
|
||||
common: {
|
||||
loading: 'Загрузка...',
|
||||
error: 'Ошибка',
|
||||
success: 'Успешно',
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
delete: 'Удалить',
|
||||
edit: 'Редактировать',
|
||||
create: 'Создать',
|
||||
close: 'Закрыть',
|
||||
confirm: 'Подтвердить',
|
||||
back: 'Назад',
|
||||
next: 'Далее',
|
||||
previous: 'Назад',
|
||||
search: 'Поиск',
|
||||
filter: 'Фильтр',
|
||||
sort: 'Сортировка',
|
||||
refresh: 'Обновить',
|
||||
download: 'Скачать',
|
||||
upload: 'Загрузить',
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
or: 'или',
|
||||
and: 'и',
|
||||
of: 'из',
|
||||
items: 'элементов',
|
||||
bytes: 'байт',
|
||||
kb: 'КБ',
|
||||
mb: 'МБ',
|
||||
gb: 'ГБ',
|
||||
tb: 'ТБ',
|
||||
openMenu: 'Открыть меню',
|
||||
closeMenu: 'Закрыть меню',
|
||||
},
|
||||
|
||||
// Ошибки
|
||||
errors: {
|
||||
notFound: 'Страница не найдена',
|
||||
notFoundDescription: 'Запрашиваемая страница не существует или была удалена.',
|
||||
unauthorized: 'Не авторизован',
|
||||
unauthorizedDescription: 'Для доступа к этой странице необходимо войти в аккаунт.',
|
||||
forbidden: 'Доступ запрещён',
|
||||
forbiddenDescription: 'У вас нет прав для просмотра этой страницы.',
|
||||
serverError: 'Ошибка сервера',
|
||||
serverErrorDescription: 'Произошла внутренняя ошибка сервера. Попробуйте позже.',
|
||||
badGateway: 'Плохой шлюз',
|
||||
badGatewayDescription: 'Сервер временно недоступен. Попробуйте позже.',
|
||||
serviceUnavailable: 'Сервис недоступен',
|
||||
serviceUnavailableDescription: 'Сервис временно недоступен. Ведутся технические работы.',
|
||||
gatewayTimeout: 'Превышено время ожидания',
|
||||
gatewayTimeoutDescription: 'Сервер не ответил вовремя. Попробуйте позже.',
|
||||
goHome: 'На главную',
|
||||
tryAgain: 'Попробовать снова',
|
||||
},
|
||||
|
||||
// Футер
|
||||
footer: {
|
||||
description: 'Надёжное облачное S3 хранилище',
|
||||
links: 'Ссылки',
|
||||
legal: 'Правовая информация',
|
||||
terms: 'Условия использования',
|
||||
privacy: 'Политика конфиденциальности',
|
||||
contact: 'Контакты',
|
||||
copyright: '© 2024 ospab.host. Все права защищены.',
|
||||
},
|
||||
|
||||
// Блог
|
||||
blog: {
|
||||
title: 'Блог',
|
||||
subtitle: 'Статьи о хостинге, S3 и облачных технологиях',
|
||||
readMore: 'Читать далее',
|
||||
published: 'Опубликовано',
|
||||
author: 'Автор',
|
||||
tags: 'Теги',
|
||||
relatedPosts: 'Похожие статьи',
|
||||
noPosts: 'Пока нет статей',
|
||||
},
|
||||
|
||||
// Тарифы
|
||||
tariffs: {
|
||||
title: 'Тарифы S3 хранилища',
|
||||
subtitle: 'Выберите подходящий план для ваших задач',
|
||||
popular: 'Популярный',
|
||||
features: 'Возможности',
|
||||
storage: 'Хранилище',
|
||||
traffic: 'Исходящий трафик',
|
||||
requests: 'Запросов',
|
||||
support: 'Поддержка',
|
||||
api: 'S3 API',
|
||||
included: 'Включено',
|
||||
unlimited: 'Безлимитно',
|
||||
perMonth: '/месяц',
|
||||
selectPlan: 'Выбрать план',
|
||||
currentPlan: 'Текущий план',
|
||||
contactUs: 'Связаться с нами',
|
||||
customPlan: 'Нужен индивидуальный план?',
|
||||
customPlanDescription: 'Свяжитесь с нами для обсуждения особых условий.',
|
||||
},
|
||||
};
|
||||
|
||||
export type TranslationKeys = typeof ru;
|
||||
92
ospabhost/frontend/src/i18n/useTranslation.ts
Normal file
92
ospabhost/frontend/src/i18n/useTranslation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocale } from '../middleware';
|
||||
import { ru, en, type TranslationKeys } from './translations';
|
||||
import type { Locale } from '../middleware/locale.utils';
|
||||
|
||||
// Словарь переводов
|
||||
const translations: Record<Locale, TranslationKeys> = {
|
||||
ru,
|
||||
en,
|
||||
};
|
||||
|
||||
type NestedKeyOf<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: K extends string
|
||||
? T[K] extends object
|
||||
? `${K}` | `${K}.${NestedKeyOf<T[K]>}`
|
||||
: `${K}`
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
type TranslationKey = NestedKeyOf<TranslationKeys>;
|
||||
|
||||
/**
|
||||
* Получить значение по вложенному ключу
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return path; // Возвращаем ключ если перевод не найден
|
||||
}
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения переводов
|
||||
*/
|
||||
export function useTranslation() {
|
||||
const { locale, setLocale } = useLocale();
|
||||
|
||||
const t = useCallback(
|
||||
(key: TranslationKey, params?: Record<string, string | number>): string => {
|
||||
const translation = getNestedValue(
|
||||
translations[locale] as unknown as Record<string, unknown>,
|
||||
key
|
||||
);
|
||||
|
||||
if (!params) return translation;
|
||||
|
||||
// Замена параметров {{param}}
|
||||
return translation.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => {
|
||||
return params[paramKey]?.toString() ?? `{{${paramKey}}}`;
|
||||
});
|
||||
},
|
||||
[locale]
|
||||
);
|
||||
|
||||
// Получить объект переводов для секции
|
||||
const tSection = useCallback(
|
||||
<K extends keyof TranslationKeys>(section: K): TranslationKeys[K] => {
|
||||
return translations[locale][section];
|
||||
},
|
||||
[locale]
|
||||
);
|
||||
|
||||
// Текущие переводы
|
||||
const translations_current = useMemo(() => translations[locale], [locale]);
|
||||
|
||||
return {
|
||||
t,
|
||||
tSection,
|
||||
locale,
|
||||
setLocale,
|
||||
translations: translations_current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить перевод без хука (для использования вне компонентов)
|
||||
*/
|
||||
export function getTranslation(locale: Locale, key: string): string {
|
||||
return getNestedValue(translations[locale] as unknown as Record<string, unknown>, key);
|
||||
}
|
||||
|
||||
export type { TranslationKey, TranslationKeys };
|
||||
@@ -2,6 +2,244 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */
|
||||
|
||||
/* Улучшенный перенос слов для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.prose {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.prose p, .prose li {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Предотвращение горизонтальной прокрутки */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Адаптация длинных слов в интерфейсе */
|
||||
.break-word-mobile {
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Усечение текста с троеточием на мобильных */
|
||||
.truncate-mobile {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line clamp utilities для совместимости */
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* ===== ГЛОБАЛЬНЫЕ АНИМАЦИИ ===== */
|
||||
|
||||
/* Fade in снизу */
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in сверху */
|
||||
@keyframes fade-in-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in слева */
|
||||
@keyframes fade-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in справа */
|
||||
@keyframes fade-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale in */
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce subtle */
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Float */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse glow */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide in from bottom for cards */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Классы анимаций */
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
animation: fade-in-down 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-left {
|
||||
animation: fade-in-left 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-right {
|
||||
animation: fade-in-right 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Задержки анимаций */
|
||||
.animation-delay-100 { animation-delay: 0.1s; }
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-300 { animation-delay: 0.3s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-500 { animation-delay: 0.5s; }
|
||||
.animation-delay-600 { animation-delay: 0.6s; }
|
||||
.animation-delay-700 { animation-delay: 0.7s; }
|
||||
.animation-delay-800 { animation-delay: 0.8s; }
|
||||
|
||||
/* Начальное состояние для анимируемых элементов */
|
||||
.animate-on-scroll {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Hover эффекты для карточек */
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Hover эффект для кнопок */
|
||||
.btn-hover {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-hover:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ===== СУЩЕСТВУЮЩИЕ АНИМАЦИИ ===== */
|
||||
|
||||
/* Анимации для модальных окон и уведомлений */
|
||||
@keyframes modal-enter {
|
||||
from {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function Unauthorized() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="401"
|
||||
title="Требуется авторизация"
|
||||
description="Для доступа к этому ресурсу необходимо войти в систему."
|
||||
title={t('errors.unauthorized')}
|
||||
description={t('errors.unauthorizedDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function Forbidden() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="403"
|
||||
title="Доступ запрещён"
|
||||
description="У вас недостаточно прав для доступа к этой странице. Обратитесь к администратору, если считаете это ошибкой."
|
||||
title={t('errors.forbidden')}
|
||||
description={t('errors.forbiddenDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" />
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
code="404"
|
||||
title="Страница не найдена"
|
||||
description="К сожалению, запрашиваемая страница не существует или была перемещена."
|
||||
title={t('errors.notFound')}
|
||||
description={t('errors.notFoundDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function ServerError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="500"
|
||||
title="Ошибка сервера"
|
||||
description="На сервере произошла ошибка. Мы уже работаем над её устранением. Попробуйте обновить страницу или вернитесь позже."
|
||||
title={t('errors.serverError')}
|
||||
description={t('errors.serverErrorDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function BadGateway() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="502"
|
||||
title="Неверный шлюз"
|
||||
description="Сервер получил недействительный ответ от вышестоящего сервера. Это временная проблема, попробуйте обновить страницу."
|
||||
title={t('errors.badGateway')}
|
||||
description={t('errors.badGatewayDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function ServiceUnavailable() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="503"
|
||||
title="Сервис недоступен"
|
||||
description="Сервер временно не может обработать запрос. Возможно, проводятся технические работы. Пожалуйста, попробуйте позже."
|
||||
title={t('errors.serviceUnavailable')}
|
||||
description={t('errors.serviceUnavailableDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function GatewayTimeout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="504"
|
||||
title="Превышено время ожидания"
|
||||
description="Сервер не дождался ответа от вышестоящего сервера. Это может быть вызвано временными проблемами с сетью."
|
||||
title={t('errors.gatewayTimeout')}
|
||||
description={t('errors.gatewayTimeoutDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { FaRocket, FaUsers, FaShieldAlt, FaChartLine, FaHeart, FaServer, FaGithub } from 'react-icons/fa';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const AboutPage = () => {
|
||||
const { locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
@@ -13,10 +19,10 @@ const AboutPage = () => {
|
||||
<div className="container mx-auto max-w-6xl relative z-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold mb-6 leading-tight">
|
||||
История ospab.host
|
||||
{isEn ? 'The Story of ospab.host' : 'История ospab.host'}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-blue-100 max-w-3xl mx-auto">
|
||||
Первый дата-центр в Великом Новгороде.
|
||||
{isEn ? 'The first data center in Veliky Novgorod.' : 'Первый дата-центр в Великом Новгороде.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,7 +36,7 @@ const AboutPage = () => {
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src="/me.jpg"
|
||||
alt="Георгий, основатель ospab.host"
|
||||
alt={isEn ? 'Georgy, founder of ospab.host' : 'Георгий, основатель ospab.host'}
|
||||
className="w-48 h-48 md:w-56 md:h-56 rounded-2xl shadow-2xl border-4 border-ospab-primary object-cover"
|
||||
width="224"
|
||||
height="224"
|
||||
@@ -39,23 +45,23 @@ const AboutPage = () => {
|
||||
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-3">Георгий</h2>
|
||||
<p className="text-xl text-ospab-primary font-semibold mb-2">Основатель и CEO</p>
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-3">{isEn ? 'Georgy' : 'Георгий'}</h2>
|
||||
<p className="text-xl text-ospab-primary font-semibold mb-2">{isEn ? 'Founder & CEO' : 'Основатель и CEO'}</p>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-gray-600">
|
||||
<span className="flex items-center gap-2">
|
||||
<FaUsers className="text-ospab-primary" />
|
||||
13 лет
|
||||
{isEn ? '13 years old' : '13 лет'}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<FaServer className="text-ospab-primary" />
|
||||
Великий Новгород
|
||||
{isEn ? 'Veliky Novgorod' : 'Великий Новгород'}
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/ospab/ospabhost8.1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:text-ospab-primary transition-colors"
|
||||
title="Исходный код проекта"
|
||||
title={isEn ? 'Project source code' : 'Исходный код проекта'}
|
||||
>
|
||||
<FaGithub className="text-ospab-primary" />
|
||||
GitHub
|
||||
@@ -64,9 +70,9 @@ const AboutPage = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
В 13 лет я решил создать то, чего не было в моём городе — современный дата-центр.
|
||||
Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту
|
||||
в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.
|
||||
{isEn
|
||||
? "At 13, I decided to create something that didn't exist in my city — a modern data center. Starting with learning technologies and working on my first hosting, I'm gradually turning a dream into reality. With the help of an investor friend, we're building the infrastructure of the future for Veliky Novgorod."
|
||||
: 'В 13 лет я решил создать то, чего не было в моём городе — современный дата-центр. Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,42 +84,43 @@ const AboutPage = () => {
|
||||
<section className="py-20 px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-center text-gray-900 mb-12">
|
||||
Наша история
|
||||
{isEn ? 'Our Story' : 'Наша история'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-white p-8 rounded-2xl border-l-4 border-ospab-primary shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<FaRocket className="text-ospab-primary" />
|
||||
Сентябрь 2025 — Начало пути
|
||||
{isEn ? 'September 2025 — The Beginning' : 'Сентябрь 2025 — Начало пути'}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Всё началось с простой идеи: создать место, где любой сможет разместить свой проект,
|
||||
сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает
|
||||
свой дата-центр, и я решил взяться за эту задачу.
|
||||
{isEn
|
||||
? "It all started with a simple idea: create a place where anyone can host their project, website, or server with maximum reliability and speed. Veliky Novgorod deserves its own data center, and I decided to take on this task."
|
||||
: 'Всё началось с простой идеи: создать место, где любой сможет разместить свой проект, сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает свой дата-центр, и я решил взяться за эту задачу.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-purple-50 to-white p-8 rounded-2xl border-l-4 border-ospab-accent shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<FaHeart className="text-ospab-accent" />
|
||||
Поддержка и развитие
|
||||
{isEn ? 'Support and Development' : 'Поддержка и развитие'}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры.
|
||||
Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг,
|
||||
а поддержка всегда рядом.
|
||||
{isEn
|
||||
? "My investor friend believed in the project and helps with infrastructure development. We're building not just a business, but a community where every client is like a friend, and support is always nearby."
|
||||
: 'Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры. Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг, а поддержка всегда рядом.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-50 to-white p-8 rounded-2xl border-l-4 border-green-500 shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<FaChartLine className="text-green-500" />
|
||||
Настоящее и будущее
|
||||
{isEn ? 'Present and Future' : 'Настоящее и будущее'}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД.
|
||||
ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.
|
||||
{isEn
|
||||
? "Currently, we're actively working on hosting and preparing infrastructure for the future data center. ospab.host is the first step towards the digital future of Veliky Novgorod, and we're just getting started."
|
||||
: 'Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД. ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,10 +132,12 @@ const AboutPage = () => {
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
Наша миссия
|
||||
{isEn ? 'Our Mission' : 'Наша миссия'}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города
|
||||
{isEn
|
||||
? "Make quality hosting accessible to everyone, and the data center — the city's pride"
|
||||
: 'Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -137,9 +146,11 @@ const AboutPage = () => {
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<FaServer className="text-3xl text-ospab-primary" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Современные технологии</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Modern Technologies' : 'Современные технологии'}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Используем новейшее оборудование и программное обеспечение для максимальной производительности
|
||||
{isEn
|
||||
? 'We use the latest equipment and software for maximum performance'
|
||||
: 'Используем новейшее оборудование и программное обеспечение для максимальной производительности'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -147,9 +158,11 @@ const AboutPage = () => {
|
||||
<div className="w-16 h-16 bg-pink-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<FaShieldAlt className="text-3xl text-ospab-accent" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Безопасность данных</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Data Security' : 'Безопасность данных'}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7
|
||||
{isEn
|
||||
? 'Customer data protection is our priority. Regular backups and 24/7 monitoring'
|
||||
: 'Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -157,9 +170,11 @@ const AboutPage = () => {
|
||||
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<FaUsers className="text-3xl text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Личная поддержка</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Personal Support' : 'Личная поддержка'}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Каждый клиент получает персональное внимание и помощь от основателя
|
||||
{isEn
|
||||
? 'Every customer receives personal attention and help from the founder'
|
||||
: 'Каждый клиент получает персональное внимание и помощь от основателя'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +186,7 @@ const AboutPage = () => {
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="bg-gradient-to-br from-ospab-primary to-blue-700 rounded-3xl shadow-2xl p-12 md:p-16 text-white">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-center mb-12">
|
||||
Почему выбирают ospab.host?
|
||||
{isEn ? 'Why choose ospab.host?' : 'Почему выбирают ospab.host?'}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
@@ -180,8 +195,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Первый ЦОД в городе</h4>
|
||||
<p className="text-blue-100">Мы создаём историю Великого Новгорода</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'First data center in the city' : 'Первый ЦОД в городе'}</h4>
|
||||
<p className="text-blue-100">{isEn ? "We're making Veliky Novgorod history" : 'Мы создаём историю Великого Новгорода'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,8 +205,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Доступные тарифы</h4>
|
||||
<p className="text-blue-100">Качественный хостинг для всех без переплат</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Affordable pricing' : 'Доступные тарифы'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'Quality hosting for everyone without overpaying' : 'Качественный хостинг для всех без переплат'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,8 +215,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Быстрая поддержка</h4>
|
||||
<p className="text-blue-100">Ответим на вопросы в любое время</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Fast support' : 'Быстрая поддержка'}</h4>
|
||||
<p className="text-blue-100">{isEn ? "We'll answer questions anytime" : 'Ответим на вопросы в любое время'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -210,8 +225,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Прозрачность</h4>
|
||||
<p className="text-blue-100">Честно о возможностях и ограничениях</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Transparency' : 'Прозрачность'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'Honest about capabilities and limitations' : 'Честно о возможностях и ограничениях'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,8 +235,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Современная инфраструктура</h4>
|
||||
<p className="text-blue-100">Актуальное ПО и оборудование</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Modern infrastructure' : 'Современная инфраструктура'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'Up-to-date software and equipment' : 'Актуальное ПО и оборудование'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,8 +245,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Мечта становится реальностью</h4>
|
||||
<p className="text-blue-100">История, которой можно гордиться</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'A dream becoming reality' : 'Мечта становится реальностью'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'A story to be proud of' : 'История, которой можно гордиться'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +263,7 @@ const AboutPage = () => {
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
Исходный код на GitHub
|
||||
{isEn ? 'Source code on GitHub' : 'Исходный код на GitHub'}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -262,23 +277,25 @@ const AboutPage = () => {
|
||||
<section className="py-20 px-4 bg-gray-50">
|
||||
<div className="container mx-auto max-w-4xl text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
Станьте частью истории
|
||||
{isEn ? 'Become part of history' : 'Станьте частью истории'}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10">
|
||||
Присоединяйтесь к ospab.host и помогите создать цифровое будущее Великого Новгорода
|
||||
{isEn
|
||||
? 'Join ospab.host and help create the digital future of Veliky Novgorod'
|
||||
: 'Присоединяйтесь к ospab.host и помогите создать цифровое будущее Великого Новгорода'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/register"
|
||||
href={localePath('/register')}
|
||||
className="px-8 py-4 bg-ospab-primary hover:bg-blue-700 text-white font-bold text-lg rounded-full transition-all transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
Начать бесплатно
|
||||
{isEn ? 'Start for free' : 'Начать бесплатно'}
|
||||
</a>
|
||||
<a
|
||||
href="/tariffs"
|
||||
href={localePath('/tariffs')}
|
||||
className="px-8 py-4 bg-white hover:bg-gray-50 text-ospab-primary font-bold text-lg rounded-full border-2 border-ospab-primary transition-all transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
Посмотреть тарифы
|
||||
{isEn ? 'View plans' : 'Посмотреть тарифы'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
@@ -25,6 +27,8 @@ const Blog: React.FC = () => {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
@@ -37,8 +41,8 @@ const Blog: React.FC = () => {
|
||||
setPosts(response.data.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки постов:', err);
|
||||
setError('Не удалось загрузить статьи');
|
||||
console.error('Error loading posts:', err);
|
||||
setError(locale === 'en' ? 'Failed to load articles' : 'Не удалось загрузить статьи');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,7 +50,7 @@ const Blog: React.FC = () => {
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
return date.toLocaleDateString(locale === 'en' ? 'en-US' : 'ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
@@ -56,7 +60,7 @@ const Blog: React.FC = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-xl text-gray-600">Загрузка...</div>
|
||||
<div className="text-xl text-gray-600">{t('common.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,9 +70,9 @@ const Blog: React.FC = () => {
|
||||
<div className="container mx-auto px-4 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">Блог</h1>
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">{t('blog.title')}</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Новости, статьи и полезные материалы о хостинге
|
||||
{t('blog.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,14 +86,14 @@ const Blog: React.FC = () => {
|
||||
{/* Posts Grid */}
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-2xl text-gray-400">📭 Статей пока нет</p>
|
||||
<p className="text-2xl text-gray-400">📭 {t('blog.noPosts')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/blog/${post.url}`}
|
||||
to={localePath(`/blog/${post.url}`)}
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow overflow-hidden group"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
@@ -103,7 +107,7 @@ const Blog: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<span className="text-4xl text-white font-bold">Статья</span>
|
||||
<span className="text-4xl text-white font-bold">{locale === 'en' ? 'Article' : 'Статья'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -122,9 +126,8 @@ const Blog: React.FC = () => {
|
||||
{/* Meta */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Автор: {post.author.username}</span>
|
||||
<span>Просмотров: {post.views}</span>
|
||||
<span>Комментариев: {post._count.comments}</span>
|
||||
<span className="truncate max-w-[150px]" title={post.author.username}>{t('blog.author')}: {post.author.username}</span>
|
||||
<span>{locale === 'en' ? 'Views' : 'Просмотров'}: {post.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ const BlogPost: React.FC = () => {
|
||||
<div className="flex items-center gap-6 text-gray-500 mb-8 pb-6 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Автор:</span>
|
||||
<span>{post.author.username}</span>
|
||||
<span className="truncate max-w-[150px]" title={post.author.username}>{post.author.username}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Дата:</span>
|
||||
@@ -257,7 +257,7 @@ const BlogPost: React.FC = () => {
|
||||
{post.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">
|
||||
<span className="font-semibold text-gray-900 truncate max-w-[150px]" title={comment.user ? comment.user.username : (comment.authorName ?? undefined)}>
|
||||
{comment.user ? comment.user.username : comment.authorName}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
|
||||
@@ -769,93 +769,90 @@ const AdminPanel = () => {
|
||||
<p className="font-semibold">{usersError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Пользователь</th>
|
||||
<th className="px-4 py-3 text-left">Email</th>
|
||||
<th className="px-4 py-3 text-left">Баланс</th>
|
||||
<th className="px-4 py-3 text-left">Сервера</th>
|
||||
<th className="px-4 py-3 text-left">Тикеты</th>
|
||||
<th className="px-4 py-3 text-left">Роли</th>
|
||||
<th className="px-4 py-3 text-right">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-gray-700">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-500">Пользователи не найдены.</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<div key={user.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||
<div className="text-sm text-gray-500 mt-2">{user.email}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-semibold ${user.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>{formatCurrency(user.balance)}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{formatNumber(user._count.buckets ?? 0)} бакетов · {formatNumber(user._count.tickets ?? 0)} тикетов</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button onClick={() => void openUserDetails(user.id)} className="text-blue-600 text-sm">Подробнее</button>
|
||||
<button onClick={() => void handleToggleAdmin(user)} disabled={roleUpdating[user.id]} className="text-purple-600 text-sm">{user.isAdmin ? 'Снять админа' : 'Дать админа'}</button>
|
||||
<button onClick={() => void handleToggleOperator(user)} disabled={roleUpdating[user.id]} className="text-indigo-600 text-sm">{user.operator ? 'Снять оператора' : 'Дать оператора'}</button>
|
||||
<button onClick={() => void handleDeleteUser(user)} disabled={deletingUserId===user.id} className="text-red-600 text-sm">{deletingUserId===user.id ? 'Удаляем...' : 'Удалить'}</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block overflow-hidden rounded-lg border border-gray-200 bg-white shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-gray-500" colSpan={7}>
|
||||
Пользователи не найдены.
|
||||
</td>
|
||||
<th className="px-4 py-3 text-left">Пользователь</th>
|
||||
<th className="px-4 py-3 text-left">Email</th>
|
||||
<th className="px-4 py-3 text-left">Баланс</th>
|
||||
<th className="px-4 py-3 text-left">Сервера</th>
|
||||
<th className="px-4 py-3 text-left">Тикеты</th>
|
||||
<th className="px-4 py-3 text-left">Роли</th>
|
||||
<th className="px-4 py-3 text-right">Действия</th>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const busy = roleUpdating[user.id] || deletingUserId === user.id;
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{user.email}</td>
|
||||
<td
|
||||
className={`px-4 py-3 font-medium ${
|
||||
user.balance >= 0 ? 'text-gray-900' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(user.balance)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.buckets ?? 0)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.tickets ?? 0)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.isAdmin ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
Админ
|
||||
</span>
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.operator ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
Оператор
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2 text-xs font-medium">
|
||||
<button
|
||||
onClick={() => void openUserDetails(user.id)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleToggleAdmin(user)}
|
||||
disabled={busy}
|
||||
className="text-purple-600 hover:text-purple-800 disabled:opacity-50"
|
||||
>
|
||||
{user.isAdmin ? 'Снять админа' : 'Дать админа'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleToggleOperator(user)}
|
||||
disabled={busy}
|
||||
className="text-indigo-600 hover:text-indigo-800 disabled:opacity-50"
|
||||
>
|
||||
{user.operator ? 'Снять оператора' : 'Дать оператора'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDeleteUser(user)}
|
||||
disabled={busy}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
{deletingUserId === user.id ? 'Удаляем...' : 'Удалить'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-gray-700">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-gray-500" colSpan={7}>Пользователи не найдены.</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const busy = roleUpdating[user.id] || deletingUserId === user.id;
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{user.email}</td>
|
||||
<td className={`px-4 py-3 font-medium ${user.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>{formatCurrency(user.balance)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.buckets ?? 0)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.tickets ?? 0)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.isAdmin ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-500'}`}>Админ</span>
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.operator ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>Оператор</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2 text-xs font-medium">
|
||||
<button onClick={() => void openUserDetails(user.id)} className="text-blue-600 hover:text-blue-800">Подробнее</button>
|
||||
<button onClick={() => void handleToggleAdmin(user)} disabled={busy} className="text-purple-600 hover:text-purple-800 disabled:opacity-50">{user.isAdmin ? 'Снять админа' : 'Дать админа'}</button>
|
||||
<button onClick={() => void handleToggleOperator(user)} disabled={busy} className="text-indigo-600 hover:text-indigo-800 disabled:opacity-50">{user.operator ? 'Снять оператора' : 'Дать оператора'}</button>
|
||||
<button onClick={() => void handleDeleteUser(user)} disabled={busy} className="text-red-600 hover:text-red-800 disabled:opacity-50">{deletingUserId === user.id ? 'Удаляем...' : 'Удалить'}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { API_URL } from '../../config/api';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
||||
@@ -15,6 +16,9 @@ interface Check {
|
||||
}
|
||||
|
||||
const Billing = () => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [amount, setAmount] = useState<number>(0);
|
||||
const [balance, setBalance] = useState<number>(0);
|
||||
const [checks, setChecks] = useState<Check[]>([]);
|
||||
@@ -136,11 +140,11 @@ const Billing = () => {
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'Зачислено';
|
||||
return isEn ? 'Approved' : 'Зачислено';
|
||||
case 'rejected':
|
||||
return 'Отклонено';
|
||||
return isEn ? 'Rejected' : 'Отклонено';
|
||||
default:
|
||||
return 'На проверке';
|
||||
return isEn ? 'Pending' : 'На проверке';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +161,9 @@ const Billing = () => {
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">Пополнение баланса</h2>
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">
|
||||
{isEn ? 'Top Up Balance' : 'Пополнение баланса'}
|
||||
</h2>
|
||||
|
||||
{/* Сообщение */}
|
||||
{message && (
|
||||
@@ -172,7 +178,7 @@ const Billing = () => {
|
||||
|
||||
{/* Текущий баланс */}
|
||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl mb-6">
|
||||
<p className="text-sm text-gray-600 mb-1">Текущий баланс</p>
|
||||
<p className="text-sm text-gray-600 mb-1">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
||||
<p className="text-3xl lg:text-4xl font-extrabold text-ospab-primary">{balance.toFixed(2)} ₽</p>
|
||||
</div>
|
||||
|
||||
@@ -181,7 +187,7 @@ const Billing = () => {
|
||||
{/* Ввод суммы */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="amount" className="block text-gray-700 font-semibold mb-2">
|
||||
Сумма пополнения (₽)
|
||||
{isEn ? 'Top-up amount (₽)' : 'Сумма пополнения (₽)'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -190,13 +196,13 @@ const Billing = () => {
|
||||
onChange={(e) => setAmount(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
min="1"
|
||||
placeholder="Введите сумму"
|
||||
placeholder={isEn ? 'Enter amount' : 'Введите сумму'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Быстрые суммы */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600 mb-2">Быстрый выбор:</p>
|
||||
<p className="text-sm text-gray-600 mb-2">{isEn ? 'Quick select:' : 'Быстрый выбор:'}</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{quickAmounts.map((quickAmount) => (
|
||||
<button
|
||||
@@ -219,18 +225,18 @@ const Billing = () => {
|
||||
disabled={amount <= 0}
|
||||
className="w-full px-5 py-3 rounded-xl text-white font-bold transition-colors bg-ospab-primary hover:bg-ospab-accent disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
Перейти к оплате
|
||||
{isEn ? 'Proceed to Payment' : 'Перейти к оплате'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Инструкция */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
||||
<p className="font-bold text-blue-800 mb-2">Инструкция по оплате</p>
|
||||
<p className="font-bold text-blue-800 mb-2">{isEn ? 'Payment Instructions' : 'Инструкция по оплате'}</p>
|
||||
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
|
||||
<li>Переведите <strong>₽{amount}</strong> по СБП или на карту</li>
|
||||
<li>Сохраните чек об оплате</li>
|
||||
<li>Загрузите чек ниже для проверки</li>
|
||||
<li>{isEn ? <>Transfer <strong>₽{amount}</strong> via SBP or to card</> : <>Переведите <strong>₽{amount}</strong> по СБП или на карту</>}</li>
|
||||
<li>{isEn ? 'Save the payment receipt' : 'Сохраните чек об оплате'}</li>
|
||||
<li>{isEn ? 'Upload the receipt below for verification' : 'Загрузите чек ниже для проверки'}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -238,18 +244,18 @@ const Billing = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* QR СБП */}
|
||||
<div className="bg-gray-100 p-4 rounded-xl">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Оплата по СБП</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Pay via SBP' : 'Оплата по СБП'}</h3>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE'} size={200} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
||||
Отсканируйте QR-код в приложении банка
|
||||
{isEn ? 'Scan QR code in your bank app' : 'Отсканируйте QR-код в приложении банка'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Номер карты */}
|
||||
<div className="bg-gray-100 p-4 rounded-xl">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Номер карты</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Card Number' : 'Номер карты'}</h3>
|
||||
<p className="text-xl font-mono font-bold text-gray-800 break-all mb-3 bg-white p-4 rounded-lg">
|
||||
{cardNumber || '0000 0000 0000 0000'}
|
||||
</p>
|
||||
@@ -257,35 +263,35 @@ const Billing = () => {
|
||||
onClick={handleCopyCard}
|
||||
className="w-full px-4 py-2 rounded-lg text-white font-semibold bg-gray-700 hover:bg-gray-800 transition"
|
||||
>
|
||||
Скопировать номер карты
|
||||
{isEn ? 'Copy card number' : 'Скопировать номер карты'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Загрузка чека */}
|
||||
<div className="bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-6 mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Загрузка чека</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Upload Receipt' : 'Загрузка чека'}</h3>
|
||||
{checkFile ? (
|
||||
<div>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Выбран файл:</strong> {checkFile.name}
|
||||
<strong>{isEn ? 'Selected file:' : 'Выбран файл:'}</strong> <span className="break-all" title={checkFile.name}>{checkFile.name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Размер: {(checkFile.size / 1024 / 1024).toFixed(2)} МБ
|
||||
{isEn ? 'Size:' : 'Размер:'} {(checkFile.size / 1024 / 1024).toFixed(2)} {isEn ? 'MB' : 'МБ'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCheckFile(null)}
|
||||
className="px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400 transition"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Remove' : 'Удалить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckUpload}
|
||||
disabled={uploadLoading}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition disabled:bg-gray-400"
|
||||
>
|
||||
{uploadLoading ? 'Загрузка...' : 'Отправить чек'}
|
||||
{uploadLoading ? (isEn ? 'Uploading...' : 'Загрузка...') : (isEn ? 'Submit receipt' : 'Отправить чек')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +299,7 @@ const Billing = () => {
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-2">
|
||||
<label className="text-ospab-primary cursor-pointer hover:underline font-semibold">
|
||||
Нажмите, чтобы выбрать файл
|
||||
{isEn ? 'Click to select a file' : 'Нажмите, чтобы выбрать файл'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
@@ -302,7 +308,7 @@ const Billing = () => {
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">JPG, PNG, PDF (до 10 МБ)</p>
|
||||
<p className="text-sm text-gray-500">{isEn ? 'JPG, PNG, PDF (up to 10 MB)' : 'JPG, PNG, PDF (до 10 МБ)'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -314,14 +320,14 @@ const Billing = () => {
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300 transition"
|
||||
>
|
||||
Изменить сумму
|
||||
{isEn ? 'Change amount' : 'Изменить сумму'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* История чеков */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">История чеков</h3>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">{isEn ? 'Receipt History' : 'История чеков'}</h3>
|
||||
{checks.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{checks.map((check) => (
|
||||
@@ -332,7 +338,7 @@ const Billing = () => {
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">{check.amount} ₽</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(check.createdAt).toLocaleString('ru-RU')}
|
||||
{new Date(check.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -362,18 +368,18 @@ const Billing = () => {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка загрузки чека:', err);
|
||||
showMessage('Не удалось загрузить чек', 'error');
|
||||
showMessage(isEn ? 'Failed to load receipt' : 'Не удалось загрузить чек', 'error');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Чек
|
||||
{isEn ? 'Receipt' : 'Чек'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">История чеков пуста</p>
|
||||
<p className="text-gray-500 text-center py-4">{isEn ? 'No receipts yet' : 'История чеков пуста'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ type CartPayload = {
|
||||
plan: CheckoutPlan;
|
||||
price: number;
|
||||
expiresAt: string;
|
||||
originalPrice?: number | null;
|
||||
promoDiscount?: number | null;
|
||||
};
|
||||
|
||||
const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/;
|
||||
@@ -187,14 +189,33 @@ const Checkout: React.FC = () => {
|
||||
|
||||
const handleApplyPromo = useCallback(async () => {
|
||||
if (!cart) return;
|
||||
if (!promoCode.trim()) {
|
||||
setPromoError('Введите промокод');
|
||||
return;
|
||||
}
|
||||
setPromoError(null);
|
||||
try {
|
||||
const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode });
|
||||
const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode: promoCode.trim() });
|
||||
const updated = res.data?.cart;
|
||||
if (updated) setCart(updated as CartPayload);
|
||||
setPromoApplied(true);
|
||||
} catch (err) {
|
||||
setPromoError(err instanceof Error ? err.message : 'Не удалось применить промокод');
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { error?: string } }; message?: string };
|
||||
const raw = axiosErr.response?.data?.error || axiosErr.message || 'Не удалось применить промокод';
|
||||
|
||||
// Friendly localized mapping
|
||||
const msg = String(raw || '').toLowerCase();
|
||||
if (msg.includes('неверный') || msg.includes('invalid')) {
|
||||
setPromoError('Неверный промокод');
|
||||
} else if (msg.includes('уже использован') || msg.includes('already used')) {
|
||||
setPromoError('Этот промокод уже использован');
|
||||
} else if (msg.includes('корзина не найдена') || msg.includes('cart')) {
|
||||
setPromoError('Корзина не найдена или просрочена');
|
||||
} else if (msg.includes('promoCode модель недоступна') || msg.includes('promocode модель')) {
|
||||
setPromoError('Серверная ошибка: PromoCode модель недоступна. Выполните prisma generate на сервере.');
|
||||
} else {
|
||||
setPromoError(raw as string);
|
||||
}
|
||||
}
|
||||
}, [cart, promoCode]);
|
||||
|
||||
@@ -287,14 +308,66 @@ const Checkout: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full md:w-1/3 mt-4 md:mt-0">
|
||||
<label className="block text-sm font-medium text-gray-700">Промокод</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input value={promoCode} onChange={(e) => setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" />
|
||||
<button onClick={handleApplyPromo} disabled={!isLoggedIn} className={`px-3 py-1 rounded ${isLoggedIn ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>{isLoggedIn ? 'Применить' : 'Войдите, чтобы применить'}</button>
|
||||
{/* Promo Code Section */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FiShoppingCart className="text-ospab-primary text-xl" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Промокод</h3>
|
||||
</div>
|
||||
{promoError && <div className="text-red-500 text-sm mt-1">{promoError}</div>}
|
||||
{promoApplied && !promoError && <div className="text-green-600 text-sm mt-1">Промокод применён</div>}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
if (promoError) setPromoError(null);
|
||||
if (promoApplied) setPromoApplied(false);
|
||||
}}
|
||||
className={`w-full border-2 rounded-xl px-4 py-3 text-lg font-mono tracking-wider uppercase transition-colors focus:outline-none focus:ring-2 focus:ring-ospab-primary/20 ${
|
||||
promoApplied ? 'border-green-500 bg-green-50' :
|
||||
promoError ? 'border-red-300 bg-red-50' :
|
||||
'border-gray-200 focus:border-ospab-primary'
|
||||
}`}
|
||||
|
||||
disabled={promoApplied || !isLoggedIn}
|
||||
maxLength={20}
|
||||
/>
|
||||
{promoApplied && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-green-500">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApplyPromo}
|
||||
disabled={!isLoggedIn || promoApplied || !promoCode.trim()}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition-all ${
|
||||
promoApplied
|
||||
? 'bg-green-100 text-green-700 cursor-default'
|
||||
: !isLoggedIn || !promoCode.trim()
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-ospab-primary text-white hover:bg-ospab-accent hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{promoApplied ? '✓ Применён' : !isLoggedIn ? 'Войдите' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
{promoError && (
|
||||
<div className="mt-3 flex items-center gap-2 text-red-600 text-sm bg-red-50 px-4 py-2 rounded-lg">
|
||||
<FiAlertCircle />
|
||||
<span>{promoError}</span>
|
||||
</div>
|
||||
)}
|
||||
{promoApplied && !promoError && (
|
||||
<div className="mt-3 flex items-center gap-2 text-green-600 text-sm bg-green-50 px-4 py-2 rounded-lg">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Промокод успешно применён! Скидка учтена в итоговой сумме.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
@@ -481,12 +554,72 @@ const Checkout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{plan && (
|
||||
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{balanceAfterPayment >= 0
|
||||
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
||||
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
|
||||
</p>
|
||||
<>
|
||||
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{balanceAfterPayment >= 0
|
||||
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
||||
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
|
||||
</p>
|
||||
|
||||
{/* Discount row */}
|
||||
{cart && typeof cart.originalPrice === 'number' && cart.originalPrice > (cart.price ?? 0) && (
|
||||
<div className="flex items-center justify-between gap-3 pt-3">
|
||||
<p className="text-gray-500">Скидка</p>
|
||||
<p className="font-semibold text-gray-900">-{formatCurrency((cart.originalPrice ?? 0) - (cart.price ?? 0))}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Promo: moved here between total and button */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Промокод</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
if (promoError) setPromoError(null);
|
||||
if (promoApplied) setPromoApplied(false);
|
||||
}}
|
||||
className={`flex-1 rounded-xl px-4 py-2 text-sm font-mono tracking-wider uppercase transition-colors focus:outline-none focus:ring-2 focus:ring-ospab-primary/20 ${
|
||||
promoApplied ? 'border-green-500 bg-green-50' :
|
||||
promoError ? 'border-red-300 bg-red-50' :
|
||||
'border-gray-200'
|
||||
}`}
|
||||
placeholder={''}
|
||||
disabled={promoApplied || !isLoggedIn}
|
||||
maxLength={20}
|
||||
/>
|
||||
<button
|
||||
onClick={handleApplyPromo}
|
||||
disabled={!isLoggedIn || promoApplied || !promoCode.trim()}
|
||||
className={`px-4 py-2 rounded-xl font-semibold transition-all ${
|
||||
promoApplied
|
||||
? 'bg-green-100 text-green-700 cursor-default'
|
||||
: !isLoggedIn || !promoCode.trim()
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-ospab-primary text-white hover:bg-ospab-accent hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{promoApplied ? '✓ Применён' : !isLoggedIn ? 'Войдите' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
{promoError && (
|
||||
<div className="mt-2 flex items-center gap-2 text-red-600 text-sm bg-red-50 px-3 py-2 rounded-lg">
|
||||
<FiAlertCircle />
|
||||
<span>{promoError}</span>
|
||||
</div>
|
||||
)}
|
||||
{promoApplied && !promoError && (
|
||||
<div className="mt-2 flex items-center gap-2 text-green-600 text-sm bg-green-50 px-3 py-2 rounded-lg">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Промокод успешно применён! Скидка учтена в итоговой сумме.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { isAxiosError } from 'axios';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import AuthContext from '../../context/authcontext';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
// Импортируем компоненты для вкладок
|
||||
import Summary from './summary';
|
||||
@@ -29,6 +30,8 @@ const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { userData, setUserData, logout, refreshUser, isInitialized } = useContext(AuthContext);
|
||||
const { locale, setLocale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [activeTab, setActiveTab] = useState('summary');
|
||||
|
||||
@@ -99,27 +102,27 @@ const Dashboard = () => {
|
||||
if (!isInitialized || loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<span className="text-gray-500 text-lg">Загрузка...</span>
|
||||
<span className="text-gray-500 text-lg">{isEn ? 'Loading...' : 'Загрузка...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Вкладки для сайдбара
|
||||
const tabs = [
|
||||
{ key: 'summary', label: 'Сводка', to: '/dashboard' },
|
||||
{ key: 'storage', label: 'Хранилище', to: '/dashboard/storage' },
|
||||
{ key: 'tickets', label: 'Тикеты', to: '/dashboard/tickets' },
|
||||
{ key: 'billing', label: 'Баланс', to: '/dashboard/billing' },
|
||||
{ key: 'settings', label: 'Настройки', to: '/dashboard/settings' },
|
||||
{ key: 'notifications', label: 'Уведомления', to: '/dashboard/notifications' },
|
||||
{ key: 'summary', label: isEn ? 'Summary' : 'Сводка', to: '/dashboard' },
|
||||
{ key: 'storage', label: isEn ? 'Storage' : 'Хранилище', to: '/dashboard/storage' },
|
||||
{ key: 'tickets', label: isEn ? 'Tickets' : 'Тикеты', to: '/dashboard/tickets' },
|
||||
{ key: 'billing', label: isEn ? 'Balance' : 'Баланс', to: '/dashboard/billing' },
|
||||
{ key: 'settings', label: isEn ? 'Settings' : 'Настройки', to: '/dashboard/settings' },
|
||||
{ key: 'notifications', label: isEn ? 'Notifications' : 'Уведомления', to: '/dashboard/notifications' },
|
||||
];
|
||||
const adminTabs = [
|
||||
{ key: 'checkverification', label: 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||
{ key: 'checkverification', label: isEn ? 'Check Verification' : 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||
];
|
||||
|
||||
const superAdminTabs = [
|
||||
{ key: 'admin', label: 'Админ-панель', to: '/dashboard/admin' },
|
||||
{ key: 'blogadmin', label: 'Блог', to: '/dashboard/blogadmin' },
|
||||
{ key: 'admin', label: isEn ? 'Admin Panel' : 'Админ-панель', to: '/dashboard/admin' },
|
||||
{ key: 'blogadmin', label: isEn ? 'Blog' : 'Блог', to: '/dashboard/blogadmin' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -147,22 +150,22 @@ const Dashboard = () => {
|
||||
`}>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800 break-words">
|
||||
Привет, {userData?.user?.username || 'Гость'}!
|
||||
{isEn ? 'Hello' : 'Привет'}, {userData?.user?.username || (isEn ? 'Guest' : 'Гость')}!
|
||||
</h2>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{isOperator && (
|
||||
<span className="inline-block px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
|
||||
Оператор
|
||||
{isEn ? 'Operator' : 'Оператор'}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full">
|
||||
Супер Админ
|
||||
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
Баланс: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||
{isEn ? 'Balance' : 'Баланс'}: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 p-6 overflow-y-auto">
|
||||
@@ -183,7 +186,7 @@ const Dashboard = () => {
|
||||
{isOperator && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-4">
|
||||
Админ панель
|
||||
{isEn ? 'Admin Panel' : 'Админ панель'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{adminTabs.map(tab => (
|
||||
@@ -204,7 +207,7 @@ const Dashboard = () => {
|
||||
{isAdmin && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<p className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-3 px-4">
|
||||
Супер Админ
|
||||
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{superAdminTabs.map(tab => (
|
||||
@@ -223,9 +226,32 @@ const Dashboard = () => {
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-6 border-t border-gray-200 text-xs text-gray-500 text-center">
|
||||
{/* Language Switcher */}
|
||||
<div className="p-4 border-t border-gray-200 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setLocale('ru')}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
locale === 'ru'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocale('en')}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
locale === 'en'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 pt-2 border-t border-gray-200 text-xs text-gray-500 text-center">
|
||||
<p>© 2025 ospab.host</p>
|
||||
<p className="mt-1">Версия 1.0.0</p>
|
||||
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,10 +267,10 @@ const Dashboard = () => {
|
||||
<div className="flex-1 flex flex-col w-full lg:w-auto">
|
||||
<div className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4 pt-16 lg:pt-4">
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-gray-900 capitalize break-words">
|
||||
{tabs.concat(adminTabs).find(t => t.key === activeTab)?.label || 'Панель управления'}
|
||||
{tabs.concat(adminTabs).concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}
|
||||
</h1>
|
||||
<p className="text-xs lg:text-sm text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString('ru-RU', {
|
||||
{new Date().toLocaleDateString(isEn ? 'en-US' : 'ru-RU', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
getNotifications,
|
||||
markAsRead,
|
||||
@@ -16,6 +17,8 @@ const NotificationsPage = () => {
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const checkPushPermission = () => {
|
||||
if ('Notification' in window) {
|
||||
@@ -83,7 +86,7 @@ const NotificationsPage = () => {
|
||||
};
|
||||
|
||||
const handleDeleteAllRead = async () => {
|
||||
if (!window.confirm('Удалить все прочитанные уведомления?')) return;
|
||||
if (!window.confirm(isEn ? 'Delete all read notifications?' : 'Удалить все прочитанные уведомления?')) return;
|
||||
|
||||
try {
|
||||
await deleteAllRead();
|
||||
@@ -98,9 +101,9 @@ const NotificationsPage = () => {
|
||||
if (success) {
|
||||
setPushEnabled(true);
|
||||
setPushPermission('granted');
|
||||
alert('Push-уведомления успешно подключены!');
|
||||
alert(isEn ? 'Push notifications enabled!' : 'Push-уведомления успешно подключены!');
|
||||
} else {
|
||||
alert('Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
|
||||
alert(isEn ? 'Failed to enable push notifications. Check your browser permissions.' : 'Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
|
||||
// Обновляем состояние на случай, если пользователь отклонил
|
||||
checkPushPermission();
|
||||
}
|
||||
@@ -108,7 +111,7 @@ const NotificationsPage = () => {
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
return date.toLocaleString(isEn ? 'en-US' : 'ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
@@ -125,11 +128,16 @@ const NotificationsPage = () => {
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const todayLabel = isEn ? 'Today' : 'Сегодня';
|
||||
const yesterdayLabel = isEn ? 'Yesterday' : 'Вчера';
|
||||
const weekLabel = isEn ? 'Last 7 days' : 'За последние 7 дней';
|
||||
const earlierLabel = isEn ? 'Earlier' : 'Ранее';
|
||||
|
||||
const groups: Record<string, Notification[]> = {
|
||||
'Сегодня': [],
|
||||
'Вчера': [],
|
||||
'За последние 7 дней': [],
|
||||
'Ранее': []
|
||||
[todayLabel]: [],
|
||||
[yesterdayLabel]: [],
|
||||
[weekLabel]: [],
|
||||
[earlierLabel]: []
|
||||
};
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
@@ -137,13 +145,13 @@ const NotificationsPage = () => {
|
||||
const notifDay = new Date(notifDate.getFullYear(), notifDate.getMonth(), notifDate.getDate());
|
||||
|
||||
if (notifDay.getTime() === today.getTime()) {
|
||||
groups['Сегодня'].push(notification);
|
||||
groups[todayLabel].push(notification);
|
||||
} else if (notifDay.getTime() === yesterday.getTime()) {
|
||||
groups['Вчера'].push(notification);
|
||||
groups[yesterdayLabel].push(notification);
|
||||
} else if (notifDate >= weekAgo) {
|
||||
groups['За последние 7 дней'].push(notification);
|
||||
groups[weekLabel].push(notification);
|
||||
} else {
|
||||
groups['Ранее'].push(notification);
|
||||
groups[earlierLabel].push(notification);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,7 +164,7 @@ const NotificationsPage = () => {
|
||||
return (
|
||||
<div className="p-4 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Уведомления</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">{isEn ? 'Notifications' : 'Уведомления'}</h1>
|
||||
|
||||
{/* Панель действий */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
@@ -171,7 +179,7 @@ const NotificationsPage = () => {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Все ({notifications.length})
|
||||
{isEn ? 'All' : 'Все'} ({notifications.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('unread')}
|
||||
@@ -181,7 +189,7 @@ const NotificationsPage = () => {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Непрочитанные ({unreadCount})
|
||||
{isEn ? 'Unread' : 'Непрочитанные'} ({unreadCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +200,7 @@ const NotificationsPage = () => {
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
Прочитать все
|
||||
{isEn ? 'Mark all as read' : 'Прочитать все'}
|
||||
</button>
|
||||
)}
|
||||
{notifications.some((n) => n.isRead) && (
|
||||
@@ -200,7 +208,7 @@ const NotificationsPage = () => {
|
||||
onClick={handleDeleteAllRead}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
|
||||
>
|
||||
Удалить прочитанные
|
||||
{isEn ? 'Delete read' : 'Удалить прочитанные'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,16 +222,16 @@ const NotificationsPage = () => {
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
||||
Подключите Push-уведомления
|
||||
{isEn ? 'Enable Push Notifications' : 'Подключите Push-уведомления'}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mb-3">
|
||||
Получайте мгновенные уведомления на компьютер или телефон при важных событиях
|
||||
{isEn ? 'Get instant notifications on your device for important events' : 'Получайте мгновенные уведомления на компьютер или телефон при важных событиях'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleEnablePush}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Включить уведомления
|
||||
{isEn ? 'Enable notifications' : 'Включить уведомления'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,11 +248,13 @@ const NotificationsPage = () => {
|
||||
Push-уведомления заблокированы
|
||||
</h3>
|
||||
<p className="text-sm text-red-700">
|
||||
Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера.
|
||||
{isEn ? 'You have blocked notifications for this site. To enable them, allow notifications in your browser settings.' : 'Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера.'}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-2">
|
||||
Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить<br/>
|
||||
Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки
|
||||
{isEn
|
||||
? <>Chrome/Edge: Click the lock icon to the left of the address bar → Notifications → Allow<br/>Firefox: Settings → Privacy & Security → Permissions → Notifications → Settings</>
|
||||
: <>Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить<br/>Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,9 +271,11 @@ const NotificationsPage = () => {
|
||||
<svg className="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет уведомлений</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">{isEn ? 'No notifications' : 'Нет уведомлений'}</h3>
|
||||
<p className="text-gray-600">
|
||||
{filter === 'unread' ? 'Все уведомления прочитаны' : 'У вас пока нет уведомлений'}
|
||||
{filter === 'unread'
|
||||
? (isEn ? 'All notifications are read' : 'Все уведомления прочитаны')
|
||||
: (isEn ? 'You have no notifications yet' : 'У вас пока нет уведомлений')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, createContext, useContext } from 'react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../../config/api';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
@@ -30,10 +31,16 @@ import {
|
||||
|
||||
type TabType = 'profile' | 'security' | 'notifications' | 'api' | 'ssh' | 'delete';
|
||||
|
||||
// Context for sharing isEn across settings tabs
|
||||
const SettingsLangContext = createContext<boolean>(false);
|
||||
const useSettingsLang = () => useContext(SettingsLangContext);
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
@@ -60,21 +67,22 @@ const SettingsPage = () => {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as TabType, label: 'Профиль' },
|
||||
{ id: 'security' as TabType, label: 'Безопасность' },
|
||||
{ id: 'notifications' as TabType, label: 'Уведомления' },
|
||||
{ id: 'api' as TabType, label: 'API ключи' },
|
||||
{ id: 'ssh' as TabType, label: 'SSH ключи' },
|
||||
{ id: 'delete' as TabType, label: 'Удаление' },
|
||||
{ id: 'profile' as TabType, label: isEn ? 'Profile' : 'Профиль' },
|
||||
{ id: 'security' as TabType, label: isEn ? 'Security' : 'Безопасность' },
|
||||
{ id: 'notifications' as TabType, label: isEn ? 'Notifications' : 'Уведомления' },
|
||||
{ id: 'api' as TabType, label: isEn ? 'API Keys' : 'API ключи' },
|
||||
{ id: 'ssh' as TabType, label: isEn ? 'SSH Keys' : 'SSH ключи' },
|
||||
{ id: 'delete' as TabType, label: isEn ? 'Delete' : 'Удаление' },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsLangContext.Provider value={isEn}>
|
||||
<div className="p-4 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Заголовок */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Настройки аккаунта</h1>
|
||||
<p className="text-gray-600 mt-2">Управление профилем, безопасностью и интеграциями</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Account Settings' : 'Настройки аккаунта'}</h1>
|
||||
<p className="text-gray-600 mt-2">{isEn ? 'Manage profile, security and integrations' : 'Управление профилем, безопасностью и интеграциями'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
@@ -113,11 +121,13 @@ const SettingsPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLangContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ ПРОФИЛЬ ============
|
||||
const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpdate: () => void }) => {
|
||||
const isEn = useSettingsLang();
|
||||
const [username, setUsername] = useState(profile?.username || '');
|
||||
const [email, setEmail] = useState(profile?.email || '');
|
||||
const [phoneNumber, setPhoneNumber] = useState(profile?.profile?.phoneNumber || '');
|
||||
@@ -145,29 +155,29 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
try {
|
||||
setSaving(true);
|
||||
await uploadAvatar(avatarFile);
|
||||
alert('Аватар загружен!');
|
||||
alert(isEn ? 'Avatar uploaded!' : 'Аватар загружен!');
|
||||
onUpdate();
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview(null);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки аватара:', error);
|
||||
alert('Ошибка загрузки аватара');
|
||||
alert(isEn ? 'Error uploading avatar' : 'Ошибка загрузки аватара');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAvatar = async () => {
|
||||
if (!confirm('Удалить аватар?')) return;
|
||||
if (!confirm(isEn ? 'Delete avatar?' : 'Удалить аватар?')) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await deleteAvatar();
|
||||
alert('Аватар удалён');
|
||||
alert(isEn ? 'Avatar deleted' : 'Аватар удалён');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления аватара:', error);
|
||||
alert('Ошибка удаления аватара');
|
||||
alert(isEn ? 'Error deleting avatar' : 'Ошибка удаления аватара');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -177,11 +187,11 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateProfile({ username, email, phoneNumber, timezone, language });
|
||||
alert('Профиль обновлён!');
|
||||
alert(isEn ? 'Profile updated!' : 'Профиль обновлён!');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления профиля:', error);
|
||||
alert('Ошибка обновления профиля');
|
||||
alert(isEn ? 'Error updating profile' : 'Ошибка обновления профиля');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -190,13 +200,13 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Профиль</h2>
|
||||
<p className="text-gray-600">Обновите информацию о своём профиле</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Profile' : 'Профиль'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Update your profile information' : 'Обновите информацию о своём профиле'}</p>
|
||||
</div>
|
||||
|
||||
{/* Аватар */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Аватар</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Avatar' : 'Аватар'}</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||
{avatarPreview || profile?.profile?.avatarUrl ? (
|
||||
@@ -213,7 +223,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="px-4 py-2 bg-ospab-primary text-white rounded-lg cursor-pointer hover:bg-ospab-accent transition">
|
||||
Выбрать файл
|
||||
{isEn ? 'Choose file' : 'Выбрать файл'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -227,7 +237,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Загрузить
|
||||
{isEn ? 'Upload' : 'Загрузить'}
|
||||
</button>
|
||||
)}
|
||||
{profile?.profile?.avatarUrl && (
|
||||
@@ -236,7 +246,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Delete' : 'Удалить'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -245,10 +255,10 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
|
||||
{/* Основная информация */}
|
||||
<div className="border-t border-gray-200 pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Основная информация</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Basic Information' : 'Основная информация'}</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Имя пользователя</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Username' : 'Имя пользователя'}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
@@ -268,7 +278,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Телефон (опционально)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Phone (optional)' : 'Телефон (опционально)'}</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
@@ -280,7 +290,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Часовой пояс</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Timezone' : 'Часовой пояс'}</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
@@ -294,7 +304,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Язык</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Language' : 'Язык'}</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
@@ -311,7 +321,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
{saving ? (isEn ? 'Saving...' : 'Сохранение...') : (isEn ? 'Save changes' : 'Сохранить изменения')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,13 +330,14 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
|
||||
// ============ БЕЗОПАСНОСТЬ ============
|
||||
const SecurityTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [view, setView] = useState<'password' | 'sessions'>('password');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Безопасность</h2>
|
||||
<p className="text-gray-600">Управление паролем и активными сеансами</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Security' : 'Безопасность'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Manage password and active sessions' : 'Управление паролем и активными сеансами'}</p>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
@@ -339,7 +350,7 @@ const SecurityTab = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Смена пароля
|
||||
{isEn ? 'Change Password' : 'Смена пароля'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('sessions')}
|
||||
@@ -349,7 +360,7 @@ const SecurityTab = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Активные сеансы
|
||||
{isEn ? 'Active Sessions' : 'Активные сеансы'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -360,6 +371,7 @@ const SecurityTab = () => {
|
||||
};
|
||||
|
||||
const PasswordChange = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -367,11 +379,11 @@ const PasswordChange = () => {
|
||||
|
||||
const getPasswordStrength = (password: string) => {
|
||||
if (password.length === 0) return { strength: 0, label: '' };
|
||||
if (password.length < 6) return { strength: 1, label: 'Слабый', color: 'bg-red-500' };
|
||||
if (password.length < 10) return { strength: 2, label: 'Средний', color: 'bg-yellow-500' };
|
||||
if (password.length < 6) return { strength: 1, label: isEn ? 'Weak' : 'Слабый', color: 'bg-red-500' };
|
||||
if (password.length < 10) return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||||
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password))
|
||||
return { strength: 2, label: 'Средний', color: 'bg-yellow-500' };
|
||||
return { strength: 3, label: 'Сильный', color: 'bg-green-500' };
|
||||
return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||||
return { strength: 3, label: isEn ? 'Strong' : 'Сильный', color: 'bg-green-500' };
|
||||
};
|
||||
|
||||
const strength = getPasswordStrength(newPassword);
|
||||
@@ -380,20 +392,20 @@ const PasswordChange = () => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('Пароли не совпадают');
|
||||
alert(isEn ? 'Passwords do not match' : 'Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await changePassword({ currentPassword, newPassword });
|
||||
alert('Пароль успешно изменён!');
|
||||
alert(isEn ? 'Password changed successfully!' : 'Пароль успешно изменён!');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (error) {
|
||||
console.error('Ошибка смены пароля:', error);
|
||||
alert('Ошибка смены пароля. Проверьте текущий пароль.');
|
||||
alert(isEn ? 'Password change error. Check current password.' : 'Ошибка смены пароля. Проверьте текущий пароль.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -402,7 +414,7 @@ const PasswordChange = () => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Текущий пароль</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Current password' : 'Текущий пароль'}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
@@ -413,7 +425,7 @@ const PasswordChange = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Новый пароль</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'New password' : 'Новый пароль'}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
@@ -434,13 +446,13 @@ const PasswordChange = () => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">Сила пароля: {strength.label}</p>
|
||||
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Password strength:' : 'Сила пароля:'} {strength.label}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Подтвердите новый пароль</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Confirm new password' : 'Подтвердите новый пароль'}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
@@ -455,13 +467,14 @@ const PasswordChange = () => {
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? 'Изменение...' : 'Изменить пароль'}
|
||||
{loading ? (isEn ? 'Changing...' : 'Изменение...') : (isEn ? 'Change password' : 'Изменить пароль')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveSessions = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loginHistory, setLoginHistory] = useState<LoginHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -494,29 +507,29 @@ const ActiveSessions = () => {
|
||||
};
|
||||
|
||||
const handleTerminate = async (id: number) => {
|
||||
if (!confirm('Вы уверены, что хотите завершить эту сессию?')) return;
|
||||
if (!confirm(isEn ? 'Are you sure you want to terminate this session?' : 'Вы уверены, что хотите завершить эту сессию?')) return;
|
||||
|
||||
try {
|
||||
await terminateSession(id);
|
||||
alert('Сеанс завершён');
|
||||
alert(isEn ? 'Session terminated' : 'Сеанс завершён');
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Ошибка завершения сеанса:', error);
|
||||
alert('Не удалось завершить сессию');
|
||||
alert(isEn ? 'Failed to terminate session' : 'Не удалось завершить сессию');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateAllOthers = async () => {
|
||||
if (!confirm('Вы уверены, что хотите завершить все остальные сессии?')) return;
|
||||
if (!confirm(isEn ? 'Are you sure you want to terminate all other sessions?' : 'Вы уверены, что хотите завершить все остальные сессии?')) return;
|
||||
|
||||
try {
|
||||
// Используем API для завершения всех остальных сессий
|
||||
await apiClient.delete('/api/sessions/others/all');
|
||||
alert('Все остальные сессии завершены');
|
||||
alert(isEn ? 'All other sessions terminated' : 'Все остальные сессии завершены');
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Ошибка завершения сессий:', error);
|
||||
alert('Не удалось завершить сессии');
|
||||
alert(isEn ? 'Failed to terminate sessions' : 'Не удалось завершить сессии');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,11 +548,11 @@ const ActiveSessions = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -559,7 +572,7 @@ const ActiveSessions = () => {
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>🚫</span>
|
||||
Завершить все остальные сессии
|
||||
{isEn ? 'Terminate all other sessions' : 'Завершить все остальные сессии'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -567,7 +580,7 @@ const ActiveSessions = () => {
|
||||
{/* Сессии в виде карточек */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-gray-600 text-center py-8 col-span-2">Нет активных сеансов</p>
|
||||
<p className="text-gray-600 text-center py-8 col-span-2">{isEn ? 'No active sessions' : 'Нет активных сеансов'}</p>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.isCurrent || session.device?.includes('Current');
|
||||
@@ -583,7 +596,7 @@ const ActiveSessions = () => {
|
||||
{isCurrent && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
✓ Текущая сессия
|
||||
✓ {isEn ? 'Current Session' : 'Текущая сессия'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -593,12 +606,12 @@ const ActiveSessions = () => {
|
||||
<div className="text-4xl">{getDeviceIcon(session.device || 'desktop')}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{session.browser || 'Неизвестный браузер'} · {session.device || 'Desktop'}
|
||||
{session.browser || (isEn ? 'Unknown browser' : 'Неизвестный браузер')} · {session.device || 'Desktop'}
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p className="flex items-center gap-2">
|
||||
<span>🌐</span>
|
||||
<span>{session.ipAddress || 'Неизвестно'}</span>
|
||||
<span>{session.ipAddress || (isEn ? 'Unknown' : 'Неизвестно')}</span>
|
||||
</p>
|
||||
{session.location && (
|
||||
<p className="flex items-center gap-2">
|
||||
@@ -608,11 +621,11 @@ const ActiveSessions = () => {
|
||||
)}
|
||||
<p className="flex items-center gap-2">
|
||||
<span>⏱️</span>
|
||||
<span>Активность: {formatRelativeTime(session.lastActivity)}</span>
|
||||
<span>{isEn ? 'Activity' : 'Активность'}: {formatRelativeTime(session.lastActivity)}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-gray-500">
|
||||
<span>🔐</span>
|
||||
<span>Вход: {new Date(session.createdAt || session.lastActivity).toLocaleString('ru-RU')}</span>
|
||||
<span>{isEn ? 'Login' : 'Вход'}: {new Date(session.createdAt || session.lastActivity).toLocaleString(isEn ? 'en-US' : 'ru-RU')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -625,7 +638,7 @@ const ActiveSessions = () => {
|
||||
onClick={() => handleTerminate(session.id)}
|
||||
className="w-full bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Завершить сессию
|
||||
{isEn ? 'Terminate Session' : 'Завершить сессию'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -644,8 +657,8 @@ const ActiveSessions = () => {
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">История входов</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">Последние 20 попыток входа в аккаунт</p>
|
||||
<h2 className="text-xl font-bold text-gray-900">{isEn ? 'Login History' : 'История входов'}</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Last 20 login attempts' : 'Последние 20 попыток входа в аккаунт'}</p>
|
||||
</div>
|
||||
<span className="text-2xl">{showHistory ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
@@ -657,16 +670,16 @@ const ActiveSessions = () => {
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Статус
|
||||
{isEn ? 'Status' : 'Статус'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP адрес
|
||||
{isEn ? 'IP Address' : 'IP адрес'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Устройство
|
||||
{isEn ? 'Device' : 'Устройство'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Дата и время
|
||||
{isEn ? 'Date and Time' : 'Дата и время'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -681,17 +694,17 @@ const ActiveSessions = () => {
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{entry.success ? '✓ Успешно' : '✗ Ошибка'}
|
||||
{entry.success ? (isEn ? '✓ Success' : '✓ Успешно') : (isEn ? '✗ Error' : '✗ Ошибка')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{entry.ipAddress}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || 'Неизвестно'}
|
||||
{entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || (isEn ? 'Unknown' : 'Неизвестно')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{new Date(entry.createdAt).toLocaleString('ru-RU')}
|
||||
{new Date(entry.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -703,12 +716,12 @@ const ActiveSessions = () => {
|
||||
|
||||
{/* Советы по безопасности */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">💡 Советы по безопасности</h3>
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">💡 {isEn ? 'Security Tips' : 'Советы по безопасности'}</h3>
|
||||
<ul className="space-y-2 text-sm text-blue-800">
|
||||
<li>• Регулярно проверяйте список активных сессий</li>
|
||||
<li>• Завершайте сессии на устройствах, которыми больше не пользуетесь</li>
|
||||
<li>• Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль</li>
|
||||
<li>• Используйте надёжные пароли и двухфакторную аутентификацию</li>
|
||||
<li>• {isEn ? 'Regularly check the list of active sessions' : 'Регулярно проверяйте список активных сессий'}</li>
|
||||
<li>• {isEn ? 'Terminate sessions on devices you no longer use' : 'Завершайте сессии на устройствах, которыми больше не пользуетесь'}</li>
|
||||
<li>• {isEn ? 'If you see suspicious activity, immediately terminate all sessions and change password' : 'Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль'}</li>
|
||||
<li>• {isEn ? 'Use strong passwords and two-factor authentication' : 'Используйте надёжные пароли и двухфакторную аутентификацию'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -717,6 +730,7 @@ const ActiveSessions = () => {
|
||||
|
||||
// ============ УВЕДОМЛЕНИЯ ============
|
||||
const NotificationsTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -748,7 +762,7 @@ const NotificationsTab = () => {
|
||||
await updateNotificationSettings({ [field]: value });
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления настроек:', error);
|
||||
alert('Ошибка сохранения настроек');
|
||||
alert(isEn ? 'Error saving settings' : 'Ошибка сохранения настроек');
|
||||
loadSettings(); // Revert
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -760,7 +774,7 @@ const NotificationsTab = () => {
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return <div className="text-center py-8 text-gray-600">Ошибка загрузки настроек</div>;
|
||||
return <div className="text-center py-8 text-gray-600">{isEn ? 'Error loading settings' : 'Ошибка загрузки настроек'}</div>;
|
||||
}
|
||||
|
||||
const emailSettings = [
|
||||
@@ -779,13 +793,13 @@ const NotificationsTab = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Уведомления</h2>
|
||||
<p className="text-gray-600">Настройте способы получения уведомлений</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Notifications' : 'Уведомления'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Configure notification methods' : 'Настройте способы получения уведомлений'}</p>
|
||||
</div>
|
||||
|
||||
{/* Email уведомления */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Email уведомления</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Email Notifications' : 'Email уведомления'}</h3>
|
||||
<div className="space-y-3">
|
||||
{emailSettings.map((setting) => (
|
||||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||||
@@ -804,7 +818,7 @@ const NotificationsTab = () => {
|
||||
|
||||
{/* Push уведомления */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Push уведомления</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Push Notifications' : 'Push уведомления'}</h3>
|
||||
<div className="space-y-3">
|
||||
{pushSettings.map((setting) => (
|
||||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||||
@@ -824,7 +838,7 @@ const NotificationsTab = () => {
|
||||
{saving && (
|
||||
<div className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-ospab-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
Сохранение...
|
||||
{isEn ? 'Saving...' : 'Сохранение...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -833,6 +847,7 @@ const NotificationsTab = () => {
|
||||
|
||||
// ============ API КЛЮЧИ ============
|
||||
const APIKeysTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -861,20 +876,20 @@ const APIKeysTab = () => {
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания ключа:', error);
|
||||
alert('Ошибка создания ключа');
|
||||
alert(isEn ? 'Error creating key' : 'Ошибка создания ключа');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Удалить этот API ключ? Приложения, использующие его, перестанут работать.')) return;
|
||||
if (!confirm(isEn ? 'Delete this API key? Applications using it will stop working.' : 'Удалить этот API ключ? Приложения, использующие его, перестанут работать.')) return;
|
||||
|
||||
try {
|
||||
await deleteAPIKey(id);
|
||||
alert('Ключ удалён');
|
||||
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления ключа:', error);
|
||||
alert('Ошибка удаления ключа');
|
||||
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -886,29 +901,29 @@ const APIKeysTab = () => {
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">API ключи</h2>
|
||||
<p className="text-gray-600">Управление ключами для интеграций</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'API Keys' : 'API ключи'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Manage integration keys' : 'Управление ключами для интеграций'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||
>
|
||||
Создать ключ
|
||||
{isEn ? 'Create Key' : 'Создать ключ'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-gray-600 text-center py-8">Нет созданных ключей</p>
|
||||
<p className="text-gray-600 text-center py-8">{isEn ? 'No keys created' : 'Нет созданных ключей'}</p>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Название</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Префикс</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Создан</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Последнее использование</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-700">Действия</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Name' : 'Название'}</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Prefix' : 'Префикс'}</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Created' : 'Создан'}</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Last Used' : 'Последнее использование'}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-700">{isEn ? 'Actions' : 'Действия'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -917,17 +932,17 @@ const APIKeysTab = () => {
|
||||
<td className="py-3 px-4">{key.name}</td>
|
||||
<td className="py-3 px-4 font-mono text-sm">{key.prefix}...</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{new Date(key.createdAt).toLocaleDateString('ru-RU')}
|
||||
{new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{key.lastUsed ? new Date(key.lastUsed).toLocaleDateString('ru-RU') : 'Никогда'}
|
||||
{key.lastUsed ? new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU') : (isEn ? 'Never' : 'Никогда')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="text-red-600 hover:text-red-700 text-sm"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Delete' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -971,16 +986,16 @@ const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreat
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-bold mb-4">Создать API ключ</h3>
|
||||
<h3 className="text-xl font-bold mb-4">Create API Key</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Название</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Мой проект"
|
||||
placeholder="My project"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
@@ -990,14 +1005,14 @@ const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreat
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1018,10 +1033,10 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<h3 className="text-xl font-bold mb-4 text-green-600">Ключ успешно создан!</h3>
|
||||
<h3 className="text-xl font-bold mb-4 text-green-600">Key created successfully!</h3>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
Сохраните этот ключ сейчас! Он больше не будет показан.
|
||||
Save this key now! It will not be shown again.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-lg p-4 mb-4 font-mono text-sm break-all">
|
||||
@@ -1032,13 +1047,13 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
||||
onClick={handleCopy}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent"
|
||||
>
|
||||
{copied ? 'Скопировано!' : 'Копировать'}
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Закрыть
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1048,6 +1063,7 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
||||
|
||||
// ============ SSH КЛЮЧИ ============
|
||||
const SSHKeysTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -1071,25 +1087,25 @@ const SSHKeysTab = () => {
|
||||
const handleAdd = async (name: string, publicKey: string) => {
|
||||
try {
|
||||
await addSSHKey({ name, publicKey });
|
||||
alert('SSH ключ добавлен');
|
||||
alert(isEn ? 'SSH key added' : 'SSH ключ добавлен');
|
||||
loadKeys();
|
||||
setShowModal(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления ключа:', error);
|
||||
alert('Ошибка добавления ключа. Проверьте формат.');
|
||||
alert(isEn ? 'Error adding key. Check the format.' : 'Ошибка добавления ключа. Проверьте формат.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Удалить этот SSH ключ?')) return;
|
||||
if (!confirm(isEn ? 'Delete this SSH key?' : 'Удалить этот SSH ключ?')) return;
|
||||
|
||||
try {
|
||||
await deleteSSHKey(id);
|
||||
alert('Ключ удалён');
|
||||
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления ключа:', error);
|
||||
alert('Ошибка удаления ключа');
|
||||
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1101,19 +1117,19 @@ const SSHKeysTab = () => {
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">SSH ключи</h2>
|
||||
<p className="text-gray-600">Управление SSH ключами для доступа к серверам</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'SSH Keys' : 'SSH ключи'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Manage SSH keys for server access' : 'Управление SSH ключами для доступа к серверам'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||
>
|
||||
Добавить ключ
|
||||
{isEn ? 'Add Key' : 'Добавить ключ'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-gray-600 text-center py-8">Нет добавленных ключей</p>
|
||||
<p className="text-gray-600 text-center py-8">{isEn ? 'No keys added' : 'Нет добавленных ключей'}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map((key) => (
|
||||
@@ -1124,15 +1140,15 @@ const SSHKeysTab = () => {
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="text-red-600 hover:text-red-700 text-sm"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Delete' : 'Удалить'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
Отпечаток: <span className="font-mono">{key.fingerprint}</span>
|
||||
{isEn ? 'Fingerprint' : 'Отпечаток'}: <span className="font-mono">{key.fingerprint}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Добавлен: {new Date(key.createdAt).toLocaleDateString('ru-RU')}
|
||||
{key.lastUsed && ` • Использован: ${new Date(key.lastUsed).toLocaleDateString('ru-RU')}`}
|
||||
{isEn ? 'Added' : 'Добавлен'}: {new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||||
{key.lastUsed && ` • ${isEn ? 'Used' : 'Использован'}: ${new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -1164,21 +1180,21 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<h3 className="text-xl font-bold mb-4">Добавить SSH ключ</h3>
|
||||
<h3 className="text-xl font-bold mb-4">Add SSH Key</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Название</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Мой ноутбук"
|
||||
placeholder="My laptop"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Публичный ключ</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Public Key</label>
|
||||
<textarea
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
@@ -1188,7 +1204,7 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg font-mono text-sm focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Скопируйте содержимое файла ~/.ssh/id_rsa.pub или ~/.ssh/id_ed25519.pub
|
||||
Copy the contents of ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -1197,14 +1213,14 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Добавление...' : 'Добавить'}
|
||||
{loading ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1215,6 +1231,7 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
|
||||
// ============ УДАЛЕНИЕ АККАУНТА ============
|
||||
const DeleteAccountTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -1231,44 +1248,44 @@ const DeleteAccountTab = () => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Ошибка экспорта данных:', error);
|
||||
alert('Ошибка экспорта данных');
|
||||
alert(isEn ? 'Error exporting data' : 'Ошибка экспорта данных');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Удаление аккаунта</h2>
|
||||
<p className="text-gray-600">Экспорт данных и безвозвратное удаление аккаунта</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Delete Account' : 'Удаление аккаунта'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Export data and permanently delete account' : 'Экспорт данных и безвозвратное удаление аккаунта'}</p>
|
||||
</div>
|
||||
|
||||
{/* Экспорт данных */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Экспорт данных</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Export Data' : 'Экспорт данных'}</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Скачайте копию всех ваших данных включая профиль, серверы, тикеты и транзакции в формате JSON.
|
||||
{isEn ? 'Download a copy of all your data including profile, servers, tickets and transactions in JSON format.' : 'Скачайте копию всех ваших данных включая профиль, серверы, тикеты и транзакции в формате JSON.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent transition"
|
||||
>
|
||||
Скачать мои данные
|
||||
{isEn ? 'Download My Data' : 'Скачать мои данные'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Удаление аккаунта */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-4">Опасная зона</h3>
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-4">{isEn ? 'Danger Zone' : 'Опасная зона'}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-red-900 font-medium">Это действие необратимо</p>
|
||||
<p className="text-red-900 font-medium">{isEn ? 'This action is irreversible' : 'Это действие необратимо'}</p>
|
||||
<p className="text-red-700 text-sm mt-1">
|
||||
Все ваши серверы будут остановлены и удалены. История платежей, тикеты и другие данные будут безвозвратно удалены.
|
||||
{isEn ? 'All your servers will be stopped and deleted. Payment history, tickets and other data will be permanently deleted.' : 'Все ваши серверы будут остановлены и удалены. История платежей, тикеты и другие данные будут безвозвратно удалены.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1276,7 +1293,7 @@ const DeleteAccountTab = () => {
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition"
|
||||
>
|
||||
Удалить мой аккаунт
|
||||
{isEn ? 'Delete My Account' : 'Удалить мой аккаунт'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useToast } from '../../hooks/useToast';
|
||||
import { getFiles, deleteFilesByBucket } from '../../utils/uploadDB';
|
||||
import type { StorageAccessKey, StorageBucket, StorageObject } from './types';
|
||||
import { formatBytes, formatCurrency, formatDate, getPlanTone, getStatusBadge, getUsagePercent } from './storage-utils';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface ObjectsResponse {
|
||||
objects: StorageObject[];
|
||||
@@ -61,7 +62,7 @@ interface UploadProgress {
|
||||
|
||||
const TEN_GIB = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
const TAB_ITEMS = [
|
||||
const TAB_ITEMS_RU = [
|
||||
{
|
||||
key: 'summary',
|
||||
label: 'Сводка',
|
||||
@@ -82,7 +83,28 @@ const TAB_ITEMS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TAB_ITEMS)[number]['key'];
|
||||
const TAB_ITEMS_EN = [
|
||||
{
|
||||
key: 'summary',
|
||||
label: 'Summary',
|
||||
icon: FiBarChart2,
|
||||
description: 'Statistics, quotas and current bucket state.',
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: 'Files',
|
||||
icon: FiFolder,
|
||||
description: 'Upload, download and manage objects.',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
icon: FiSettings,
|
||||
description: 'Access rights, versioning and API keys.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TAB_ITEMS_RU)[number]['key'];
|
||||
|
||||
type LoadObjectsOptions = {
|
||||
reset?: boolean;
|
||||
@@ -175,7 +197,7 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
</span>
|
||||
<span className="flex-1 font-medium text-gray-800">
|
||||
{node.name}
|
||||
<span className="ml-2 text-xs text-gray-400 font-normal">({fileCount} файл.)</span>
|
||||
<span className="ml-2 text-xs text-gray-400 font-normal">({fileCount})</span>
|
||||
</span>
|
||||
<span className="w-24 text-right text-xs text-gray-500">{formatBytes(folderSize)}</span>
|
||||
<span className="w-40 text-right text-xs text-gray-400">—</span>
|
||||
@@ -226,7 +248,7 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
<span className="text-gray-400">
|
||||
<FiFile />
|
||||
</span>
|
||||
<span className="flex-1 font-mono text-xs text-gray-700 break-all">{node.name}</span>
|
||||
<span className="flex-1 font-mono text-sm text-gray-700 break-words" title={node.name}>{node.name}</span>
|
||||
<span className="w-24 text-right text-xs text-gray-600">{formatBytes(node.size)}</span>
|
||||
<span className="w-40 text-right text-xs text-gray-500">
|
||||
{node.lastModified ? formatDate(node.lastModified, true) : '—'}
|
||||
@@ -242,7 +264,6 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
className="inline-flex items-center gap-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<FiDownload />
|
||||
Скачать
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
@@ -257,6 +278,9 @@ const StorageBucketPage: React.FC = () => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const TAB_ITEMS = isEn ? TAB_ITEMS_EN : TAB_ITEMS_RU;
|
||||
|
||||
const objectPrefixRef = useRef('');
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -378,7 +402,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
const fetchBucket = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
if (!bucketIdValid) {
|
||||
setBucket(null);
|
||||
setBucketError('Некорректный идентификатор бакета');
|
||||
setBucketError(isEn ? 'Invalid bucket ID' : 'Некорректный идентификатор бакета');
|
||||
setBucketLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +418,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setBucket(data.bucket);
|
||||
setBucketError(null);
|
||||
} catch (error) {
|
||||
let message = 'Не удалось загрузить бакет';
|
||||
let message = isEn ? 'Failed to load bucket' : 'Не удалось загрузить бакет';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -444,7 +468,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setObjectsCursor(data.nextCursor ?? null);
|
||||
} catch (error) {
|
||||
console.error('[StorageBucket] Не удалось получить список объектов', error);
|
||||
addToast('Не удалось загрузить список объектов', 'error');
|
||||
addToast(isEn ? 'Failed to load objects list' : 'Не удалось загрузить список объектов', 'error');
|
||||
} finally {
|
||||
setObjectsLoading(false);
|
||||
setObjectsLoadingMore(false);
|
||||
@@ -462,7 +486,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setAccessKeys(data.keys);
|
||||
} catch (error) {
|
||||
console.error('[StorageBucket] Не удалось получить ключи доступа', error);
|
||||
addToast('Не удалось загрузить ключи доступа', 'error');
|
||||
addToast(isEn ? 'Failed to load access keys' : 'Не удалось загрузить ключи доступа', 'error');
|
||||
} finally {
|
||||
setAccessKeysLoading(false);
|
||||
}
|
||||
@@ -481,7 +505,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
}
|
||||
dispatchBucketsRefresh();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось обновить настройки бакета';
|
||||
let message = isEn ? 'Failed to update bucket settings' : 'Не удалось обновить настройки бакета';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -496,7 +520,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = !bucket.public;
|
||||
updateBucketSettings({ public: next }, next ? 'Публичный доступ включён' : 'Публичный доступ отключён');
|
||||
updateBucketSettings({ public: next }, next ? (isEn ? 'Public access enabled' : 'Публичный доступ включён') : (isEn ? 'Public access disabled' : 'Публичный доступ отключён'));
|
||||
}, [bucket, updateBucketSettings]);
|
||||
|
||||
const toggleVersioning = useCallback(() => {
|
||||
@@ -504,7 +528,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = !bucket.versioning;
|
||||
updateBucketSettings({ versioning: next }, next ? 'Версионирование включено' : 'Версионирование отключено');
|
||||
updateBucketSettings({ versioning: next }, next ? (isEn ? 'Versioning enabled' : 'Версионирование включено') : (isEn ? 'Versioning disabled' : 'Версионирование отключено'));
|
||||
}, [bucket, updateBucketSettings]);
|
||||
|
||||
const toggleAutoRenew = useCallback(() => {
|
||||
@@ -512,7 +536,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = !bucket.autoRenew;
|
||||
updateBucketSettings({ autoRenew: next }, next ? 'Автопродление включено' : 'Автопродление отключено');
|
||||
updateBucketSettings({ autoRenew: next }, next ? (isEn ? 'Auto-renewal enabled' : 'Автопродление включено') : (isEn ? 'Auto-renewal disabled' : 'Автопродление отключено'));
|
||||
}, [bucket, updateBucketSettings]);
|
||||
|
||||
const handleRefreshBucket = useCallback(() => {
|
||||
@@ -564,7 +588,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
|
||||
const handleDownloadObject = useCallback(async (object: StorageObject) => {
|
||||
if (object.size >= TEN_GIB) {
|
||||
const confirmed = window.confirm('Файл весит больше 10 ГБ. Скачивание может занять продолжительное время. Продолжить?');
|
||||
const confirmed = window.confirm(isEn ? 'File is larger than 10 GB. Download may take a long time. Continue?' : 'Файл весит больше 10 ГБ. Скачивание может занять продолжительное время. Продолжить?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -586,7 +610,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
let message = 'Не удалось скачать объект';
|
||||
let message = isEn ? 'Failed to download object' : 'Не удалось скачать объект';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -613,7 +637,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
fetchBucket({ silent: true });
|
||||
dispatchBucketsRefresh();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось удалить объекты';
|
||||
let message = isEn ? 'Failed to delete objects' : 'Не удалось удалить объекты';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -661,7 +685,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
// Проверяем отмену
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Загрузка отменена');
|
||||
throw new Error(isEn ? 'Upload cancelled' : 'Загрузка отменена');
|
||||
}
|
||||
|
||||
const file = files[i];
|
||||
@@ -749,7 +773,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
fetchBucket({ silent: true });
|
||||
dispatchBucketsRefresh();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось загрузить файлы';
|
||||
let message = isEn ? 'Failed to upload files' : 'Не удалось загрузить файлы';
|
||||
if (error instanceof Error && error.message) {
|
||||
message = error.message;
|
||||
}
|
||||
@@ -774,8 +798,8 @@ const StorageBucketPage: React.FC = () => {
|
||||
setUploading(false);
|
||||
setUploadProgress({});
|
||||
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: 0 });
|
||||
addToast('Загрузка отменена', 'info');
|
||||
}, [addToast]);
|
||||
addToast(isEn ? 'Upload cancelled' : 'Загрузка отменена', 'info');
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const handleClickSelectFiles = useCallback(() => {
|
||||
if (fileDialogOpenRef.current || uploading) {
|
||||
@@ -809,7 +833,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
|
||||
const handleUriUpload = useCallback(async () => {
|
||||
if (!uriUploadUrl.trim()) {
|
||||
addToast('Введите URL', 'error');
|
||||
addToast(isEn ? 'Enter URL' : 'Введите URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -865,15 +889,15 @@ const StorageBucketPage: React.FC = () => {
|
||||
addToast(`Файл "${fileName}" загружен`, 'success');
|
||||
} else {
|
||||
console.error('[URI Upload] Ответ не содержит blob:', response.data);
|
||||
addToast('Сервер не вернул данные файла', 'error');
|
||||
addToast(isEn ? 'Server returned no file data' : 'Сервер не вернул данные файла', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[URI Upload] Ошибка:', error);
|
||||
let message = 'Не удалось загрузить по URI';
|
||||
console.error('[URI Upload] Error:', error);
|
||||
let message = isEn ? 'Failed to upload by URI' : 'Не удалось загрузить по URI';
|
||||
if (error instanceof Error && error.message === 'canceled') {
|
||||
message = 'Загрузка отменена';
|
||||
message = isEn ? 'Upload cancelled' : 'Загрузка отменена';
|
||||
} else if (isAxiosError(error) && error.response?.data?.error) {
|
||||
console.error('[URI Upload] Ошибка от сервера:', error.response.data.error);
|
||||
console.error('[URI Upload] Server error:', error.response.data.error);
|
||||
message = error.response.data.error;
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
@@ -883,7 +907,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setUriUploadLoading(false);
|
||||
uriUploadAbortControllerRef.current = null;
|
||||
}
|
||||
}, [uriUploadUrl, performUpload, addToast, bucketNumber]);
|
||||
}, [uriUploadUrl, performUpload, addToast, bucketNumber, isEn]);
|
||||
|
||||
const handleCancelUriUpload = useCallback(() => {
|
||||
if (uriUploadAbortControllerRef.current) {
|
||||
@@ -891,8 +915,8 @@ const StorageBucketPage: React.FC = () => {
|
||||
uriUploadAbortControllerRef.current = null;
|
||||
}
|
||||
setUriUploadLoading(false);
|
||||
addToast('Загрузка отменена', 'info');
|
||||
}, [addToast]);
|
||||
addToast(isEn ? 'Upload cancelled' : 'Загрузка отменена', 'info');
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const handleUploadInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
@@ -914,7 +938,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return file.size > 0 || file.type !== '';
|
||||
});
|
||||
if (fileArray.length === 0) {
|
||||
addToast('Папка пуста или не содержит файлов', 'warning');
|
||||
addToast(isEn ? 'Folder is empty or contains no files' : 'Папка пуста или не содержит файлов', 'warning');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -961,7 +985,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
}
|
||||
addToast(`${label} скопирован`, 'success');
|
||||
} catch {
|
||||
addToast('Не удалось скопировать в буфер обмена', 'error');
|
||||
addToast(isEn ? 'Failed to copy to clipboard' : 'Не удалось скопировать в буфер обмена', 'error');
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
@@ -976,10 +1000,10 @@ const StorageBucketPage: React.FC = () => {
|
||||
});
|
||||
setNewKeyLabel('');
|
||||
setLastCreatedKey(data.key);
|
||||
addToast('Создан новый ключ доступа', 'success');
|
||||
addToast(isEn ? 'New access key created' : 'Создан новый ключ доступа', 'success');
|
||||
fetchAccessKeys();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось создать ключ';
|
||||
let message = isEn ? 'Failed to create key' : 'Не удалось создать ключ';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -990,7 +1014,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
}, [addToast, bucketIdValid, bucketNumber, creatingKey, fetchAccessKeys, newKeyLabel]);
|
||||
|
||||
const handleRevokeAccessKey = useCallback(async (keyId: number) => {
|
||||
const confirmed = window.confirm('Удалить ключ доступа? После удаления восстановить его будет невозможно.');
|
||||
const confirmed = window.confirm(isEn ? 'Delete access key? Recovery will be impossible after deletion.' : 'Удалить ключ доступа? После удаления восстановить его будет невозможно.');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -998,9 +1022,9 @@ const StorageBucketPage: React.FC = () => {
|
||||
try {
|
||||
await apiClient.delete(`/api/storage/buckets/${bucketNumber}/access-keys/${keyId}`);
|
||||
setAccessKeys((prev) => prev.filter((key) => key.id !== keyId));
|
||||
addToast('Ключ удалён', 'success');
|
||||
addToast(isEn ? 'Key deleted' : 'Ключ удалён', 'success');
|
||||
} catch (error) {
|
||||
let message = 'Не удалось удалить ключ';
|
||||
let message = isEn ? 'Failed to delete key' : 'Не удалось удалить ключ';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -1064,7 +1088,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!bucketIdValid) {
|
||||
setBucket(null);
|
||||
setBucketError('Некорректный идентификатор бакета');
|
||||
setBucketError(isEn ? 'Invalid bucket ID' : 'Некорректный идентификатор бакета');
|
||||
setBucketLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -1103,31 +1127,31 @@ const StorageBucketPage: React.FC = () => {
|
||||
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Регион</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Region' : 'Регион'}</p>
|
||||
<p className="font-semibold text-gray-800">{bucket.regionDetails?.name ?? bucket.region}</p>
|
||||
<p className="text-xs text-gray-500">{bucket.regionDetails?.endpoint ?? bucket.regionDetails?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Storage Class' : 'Класс хранения'}</p>
|
||||
<p className="font-semibold text-gray-800">{bucket.storageClassDetails?.name ?? bucket.storageClass}</p>
|
||||
<p className="text-xs text-gray-500">{bucket.storageClassDetails?.description ?? bucket.storageClassDetails?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Тариф</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Plan' : 'Тариф'}</p>
|
||||
<p className="font-semibold text-gray-800">{bucketPlanName}</p>
|
||||
<p className="text-xs text-gray-500">Стоимость: {bucketPrice}</p>
|
||||
<p className="text-xs text-gray-500">{isEn ? 'Cost' : 'Стоимость'}: {bucketPrice}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Биллинг</p>
|
||||
<p className="font-semibold text-gray-800">Следующее списание: {formatDate(bucket.nextBillingDate)}</p>
|
||||
<p className="text-xs text-gray-500">Последнее списание: {formatDate(bucket.lastBilledAt)}</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Billing' : 'Биллинг'}</p>
|
||||
<p className="font-semibold text-gray-800">{isEn ? 'Next charge' : 'Следующее списание'}: {formatDate(bucket.nextBillingDate)}</p>
|
||||
<p className="text-xs text-gray-500">{isEn ? 'Last charge' : 'Последнее списание'}: {formatDate(bucket.lastBilledAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Использовано: {formatBytes(bucket.usedBytes)}</span>
|
||||
<span>Квота: {bucket.quotaGb} GB</span>
|
||||
<span>{isEn ? 'Used' : 'Использовано'}: {formatBytes(bucket.usedBytes)}</span>
|
||||
<span>{isEn ? 'Quota' : 'Квота'}: {bucket.quotaGb} GB</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
@@ -1138,19 +1162,19 @@ const StorageBucketPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-2">
|
||||
<span>{bucketUsagePercent.toFixed(1)}% квоты использовано</span>
|
||||
<span>Объектов: {bucket.objectCount}</span>
|
||||
<span>Синхронизация: {formatDate(bucket.usageSyncedAt, true)}</span>
|
||||
<span>{bucketUsagePercent.toFixed(1)}% {isEn ? 'quota used' : 'квоты использовано'}</span>
|
||||
<span>{isEn ? 'Objects' : 'Объектов'}: {bucket.objectCount}</span>
|
||||
<span>{isEn ? 'Sync' : 'Синхронизация'}: {formatDate(bucket.usageSyncedAt, true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Создан</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Created' : 'Создан'}</p>
|
||||
<p className="font-semibold text-gray-800">{formatDate(bucket.createdAt)}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Обновлён</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Updated' : 'Обновлён'}</p>
|
||||
<p className="font-semibold text-gray-800">{formatDate(bucket.updatedAt, true)}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
@@ -1339,7 +1363,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
<div className="mt-6 space-y-4 bg-ospab-primary/5 p-4 rounded-lg border border-ospab-primary/20">
|
||||
{uploadStats.currentFile && (
|
||||
<div className="text-sm text-gray-700 font-semibold">
|
||||
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
|
||||
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary truncate max-w-[200px] inline-block align-bottom" title={uploadStats.currentFile}>{uploadStats.currentFile}</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadProgress.__total__ && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { StorageBucket } from './types';
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -54,6 +55,8 @@ const StoragePage: React.FC = () => {
|
||||
|
||||
const { addToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [bucketActions, setBucketActions] = useState<Record<number, boolean>>({});
|
||||
|
||||
const fetchBuckets = useCallback(async (notify = false) => {
|
||||
@@ -63,16 +66,16 @@ const StoragePage: React.FC = () => {
|
||||
setBuckets(response.data?.buckets ?? []);
|
||||
setError(null);
|
||||
if (notify) {
|
||||
addToast('Список бакетов обновлён', 'success');
|
||||
addToast(isEn ? 'Bucket list updated' : 'Список бакетов обновлён', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Storage] Не удалось загрузить бакеты', err);
|
||||
setError('Не удалось загрузить список хранилищ');
|
||||
addToast('Не удалось получить список бакетов', 'error');
|
||||
setError(isEn ? 'Failed to load storage list' : 'Не удалось загрузить список хранилищ');
|
||||
addToast(isEn ? 'Failed to get bucket list' : 'Не удалось получить список бакетов', 'error');
|
||||
} finally {
|
||||
setLoadingBuckets(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const fetchStatus = useCallback(async (notify = false) => {
|
||||
try {
|
||||
@@ -80,17 +83,17 @@ const StoragePage: React.FC = () => {
|
||||
const response = await apiClient.get<StorageStatus>('/api/storage/status');
|
||||
setStatus(response.data);
|
||||
if (notify && response.data.minio.connected) {
|
||||
addToast('Подключение к MinIO активно', 'success');
|
||||
addToast(isEn ? 'MinIO connection active' : 'Подключение к MinIO активно', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Storage] Не удалось получить статус', err);
|
||||
if (notify) {
|
||||
addToast('Не удалось обновить статус MinIO', 'warning');
|
||||
addToast(isEn ? 'Failed to update MinIO status' : 'Не удалось обновить статус MinIO', 'warning');
|
||||
}
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const setBucketBusy = useCallback((id: number, busy: boolean) => {
|
||||
setBucketActions((prev) => {
|
||||
@@ -222,9 +225,9 @@ const StoragePage: React.FC = () => {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<FiDatabase className="text-ospab-primary" />
|
||||
S3 Хранилище
|
||||
{isEn ? 'S3 Storage' : 'S3 Хранилище'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">Управление объектными бакетами и статус облачного хранилища</p>
|
||||
<p className="text-gray-600 mt-1">{isEn ? 'Manage object buckets and cloud storage status' : 'Управление объектными бакетами и статус облачного хранилища'}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
@@ -232,14 +235,14 @@ const StoragePage: React.FC = () => {
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
|
||||
>
|
||||
<FiRefreshCw className={loadingBuckets ? 'animate-spin' : ''} />
|
||||
Обновить список
|
||||
{isEn ? 'Refresh list' : 'Обновить список'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
className="px-5 py-2.5 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 shadow-lg transition-all flex items-center gap-2"
|
||||
>
|
||||
<FiPlus />
|
||||
Создать бакет
|
||||
{isEn ? 'Create bucket' : 'Создать бакет'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,9 +264,11 @@ const StoragePage: React.FC = () => {
|
||||
<FiAlertTriangle className="text-red-500 text-2xl" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">Статус подключения MinIO</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-800">{isEn ? 'MinIO Connection Status' : 'Статус подключения MinIO'}</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{minioStatus?.connected ? 'Подключение установлено' : 'Нет связи с хранилищем. Попробуйте обновить статус.'}
|
||||
{minioStatus?.connected
|
||||
? (isEn ? 'Connection established' : 'Подключение установлено')
|
||||
: (isEn ? 'No connection to storage. Try refreshing status.' : 'Нет связи с хранилищем. Попробуйте обновить статус.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,12 +277,12 @@ const StoragePage: React.FC = () => {
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
|
||||
>
|
||||
<FiRefreshCw className={loadingStatus ? 'animate-spin' : ''} />
|
||||
Проверить статус
|
||||
{isEn ? 'Check status' : 'Проверить статус'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingStatus ? (
|
||||
<div className="px-6 py-8 text-sm text-gray-500">Проверяем подключение к MinIO...</div>
|
||||
<div className="px-6 py-8 text-sm text-gray-500">{isEn ? 'Checking MinIO connection...' : 'Проверяем подключение к MinIO...'}</div>
|
||||
) : status ? (
|
||||
<div className="px-6 py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
@@ -287,11 +292,11 @@ const StoragePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FiInfo className="text-ospab-primary" />
|
||||
<span>Префикс бакетов: <span className="font-semibold text-gray-800">{minioStatus?.bucketPrefix || '—'}</span></span>
|
||||
<span>{isEn ? 'Bucket prefix:' : 'Префикс бакетов:'} <span className="font-semibold text-gray-800">{minioStatus?.bucketPrefix || '—'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FiInfo className="text-ospab-primary" />
|
||||
<span>Всего бакетов на сервере: <span className="font-semibold text-gray-800">{minioStatus?.availableBuckets ?? '—'}</span></span>
|
||||
<span>{isEn ? 'Total buckets on server:' : 'Всего бакетов на сервере:'} <span className="font-semibold text-gray-800">{minioStatus?.availableBuckets ?? '—'}</span></span>
|
||||
</div>
|
||||
{minioStatus?.error && !minioStatus.connected && (
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
@@ -303,45 +308,45 @@ const StoragePage: React.FC = () => {
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Регион по умолчанию</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.region?.name ?? 'Не выбран'}</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Default region' : 'Регион по умолчанию'}</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.region?.name ?? (isEn ? 'Not selected' : 'Не выбран')}</p>
|
||||
<p className="text-xs text-gray-500">{status.defaults.region?.endpoint ?? status.defaults.region?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения по умолчанию</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.storageClass?.name ?? 'Не выбран'}</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Default storage class' : 'Класс хранения по умолчанию'}</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.storageClass?.name ?? (isEn ? 'Not selected' : 'Не выбран')}</p>
|
||||
<p className="text-xs text-gray-500">{status.defaults.storageClass?.description ?? status.defaults.storageClass?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<span>Активных тарифов: <span className="font-semibold text-gray-800">{status.plans.length}</span></span>
|
||||
<span>Регионов: <span className="font-semibold text-gray-800">{status.regions.length}</span></span>
|
||||
<span>Классов хранения: <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
|
||||
<span>{isEn ? 'Active plans:' : 'Активных тарифов:'} <span className="font-semibold text-gray-800">{status.plans.length}</span></span>
|
||||
<span>{isEn ? 'Regions:' : 'Регионов:'} <span className="font-semibold text-gray-800">{status.regions.length}</span></span>
|
||||
<span>{isEn ? 'Storage classes:' : 'Классов хранения:'} <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-8 text-sm text-gray-500 flex items-center gap-2">
|
||||
<FiInfo />
|
||||
Нет данных о статусе хранилища. Попробуйте обновить.
|
||||
{isEn ? 'No storage status data. Try refreshing.' : 'Нет данных о статусе хранилища. Попробуйте обновить.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Всего бакетов</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">{isEn ? 'Total buckets' : 'Всего бакетов'}</p>
|
||||
<p className="text-3xl font-bold text-gray-800">{summary.totalBuckets}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Автопродление активировано: {summary.autoRenewCount}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{isEn ? 'Auto-renewal enabled:' : 'Автопродление активировано:'} {summary.autoRenewCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Использовано данных</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">{isEn ? 'Data used' : 'Использовано данных'}</p>
|
||||
<p className="text-2xl font-semibold text-gray-800">{formatBytes(summary.totalUsedBytes)}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Глобальная загрузка: {summary.globalUsagePercent.toFixed(1)}%</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{isEn ? 'Global usage:' : 'Глобальная загрузка:'} {summary.globalUsagePercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Суммарная квота</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">{isEn ? 'Total quota' : 'Суммарная квота'}</p>
|
||||
<p className="text-2xl font-semibold text-gray-800">{summary.totalQuotaGb} GB</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Мин. ежемесячный тариф: {summary.lowestPrice !== null ? formatCurrency(summary.lowestPrice) : '—'}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{isEn ? 'Min. monthly rate:' : 'Мин. ежемесячный тариф:'} {summary.lowestPrice !== null ? formatCurrency(summary.lowestPrice) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,14 +358,14 @@ const StoragePage: React.FC = () => {
|
||||
) : buckets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<FiDatabase className="text-6xl text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Нет активных хранилищ</h3>
|
||||
<p className="text-gray-600 mb-6">Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.</p>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">{isEn ? 'No active storage' : 'Нет активных хранилищ'}</h3>
|
||||
<p className="text-gray-600 mb-6">{isEn ? 'Create your first S3 bucket for storing files, backups and media content.' : 'Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 shadow-lg transition-all inline-flex items-center gap-2"
|
||||
>
|
||||
<FiPlus />
|
||||
Выбрать тариф
|
||||
{isEn ? 'Choose plan' : 'Выбрать тариф'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -384,7 +389,7 @@ const StoragePage: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-gray-800">{bucket.name}</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 truncate max-w-[200px] sm:max-w-[300px]" title={bucket.name}>{bucket.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${planTone}`}>
|
||||
{planName}
|
||||
</span>
|
||||
@@ -392,7 +397,7 @@ const StoragePage: React.FC = () => {
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">ID бакета: {bucket.id}</p>
|
||||
<p className="text-xs text-gray-500">{isEn ? 'Bucket ID:' : 'ID бакета:'} {bucket.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
import type { UserData, Ticket } from './types';
|
||||
|
||||
@@ -7,6 +8,9 @@ interface SummaryProps {
|
||||
}
|
||||
|
||||
const Summary = ({ userData }: SummaryProps) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
// Фильтрация открытых тикетов
|
||||
const openTickets = Array.isArray(userData.tickets)
|
||||
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
|
||||
@@ -14,21 +18,30 @@ const Summary = ({ userData }: SummaryProps) => {
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl">
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">Сводка по аккаунту</h2>
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">
|
||||
{isEn ? 'Account Summary' : 'Сводка по аккаунту'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6 mb-6 lg:mb-8">
|
||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl flex flex-col items-start">
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">Баланс:</p>
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">{isEn ? 'Balance:' : 'Баланс:'}</p>
|
||||
<p className="text-3xl lg:text-4xl font-extrabold text-ospab-primary mt-2 break-words">₽ {userData.balance?.toFixed ? userData.balance.toFixed(2) : Number(userData.balance).toFixed(2)}</p>
|
||||
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">Пополнить баланс →</Link>
|
||||
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">
|
||||
{isEn ? 'Top up balance →' : 'Пополнить баланс →'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl flex flex-col items-start">
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">Открытые тикеты:</p>
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">{isEn ? 'Open Tickets:' : 'Открытые тикеты:'}</p>
|
||||
<p className="text-3xl lg:text-4xl font-extrabold text-gray-800 mt-2">{openTickets.length}</p>
|
||||
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">Служба поддержки →</Link>
|
||||
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">
|
||||
{isEn ? 'Support →' : 'Служба поддержки →'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-lg text-gray-500">
|
||||
Добро пожаловать в ваш личный кабинет, {userData.user?.username || 'пользователь'}! Здесь вы можете быстро получить доступ к основным разделам.
|
||||
{isEn
|
||||
? `Welcome to your dashboard, ${userData.user?.username || 'user'}! Here you can quickly access the main sections.`
|
||||
: `Добро пожаловать в ваш личный кабинет, ${userData.user?.username || 'пользователь'}! Здесь вы можете быстро получить доступ к основным разделам.`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UserData } from './types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
@@ -35,6 +36,8 @@ type TicketsPageProps = {
|
||||
|
||||
const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -69,11 +72,11 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт' }
|
||||
open: { color: 'bg-green-100 text-green-800', text: isEn ? 'Open' : 'Открыт' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: isEn ? 'In Progress' : 'В работе' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: isEn ? 'Awaiting Reply' : 'Ожидает ответа' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: isEn ? 'Resolved' : 'Решён' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: isEn ? 'Closed' : 'Закрыт' }
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.open;
|
||||
@@ -87,10 +90,10 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий' }
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: isEn ? 'Urgent' : 'Срочно' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: isEn ? 'High' : 'Высокий' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: isEn ? 'Normal' : 'Обычный' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: isEn ? 'Low' : 'Низкий' }
|
||||
};
|
||||
|
||||
const badge = badges[priority] || badges.normal;
|
||||
@@ -121,11 +124,11 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -133,7 +136,7 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка тикетов...</p>
|
||||
<p className="mt-4 text-gray-600">{isEn ? 'Loading tickets...' : 'Загрузка тикетов...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -145,15 +148,15 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Управляйте вашими обращениями в службу поддержки</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||
<p className="text-gray-600">{isEn ? 'Manage your support requests' : 'Управляйте вашими обращениями в службу поддержки'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>➕</span>
|
||||
Создать тикет
|
||||
{isEn ? 'Create Ticket' : 'Создать тикет'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -162,50 +165,50 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Статус</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Status' : 'Статус'}</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="awaiting_reply">Ожидает ответа</option>
|
||||
<option value="resolved">Решён</option>
|
||||
<option value="closed">Закрыт</option>
|
||||
<option value="all">{isEn ? 'All Statuses' : 'Все статусы'}</option>
|
||||
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Категория</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Category' : 'Категория'}</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="all">{isEn ? 'All Categories' : 'Все категории'}</option>
|
||||
<option value="general">{isEn ? 'General Questions' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Приоритет</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Priority' : 'Приоритет'}</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="all">{isEn ? 'All Priorities' : 'Все приоритеты'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,13 +217,13 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
{/* Tickets Grid */}
|
||||
{tickets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
|
||||
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{isEn ? 'No Tickets' : 'Нет тикетов'}</h3>
|
||||
<p className="text-gray-600 mb-6">{isEn ? 'You have no open support tickets yet' : 'У вас пока нет открытых тикетов поддержки'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Создать первый тикет
|
||||
{isEn ? 'Create First Ticket' : 'Создать первый тикет'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -251,13 +254,13 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||
<span>{ticket.responses?.length || 0} ответов</span>
|
||||
<span>{ticket.responses?.length || 0} {isEn ? 'replies' : 'ответов'}</span>
|
||||
{ticket.closedAt && (
|
||||
<span>Закрыт</span>
|
||||
<span>{isEn ? 'Closed' : 'Закрыт'}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
||||
Открыть →
|
||||
{isEn ? 'Open →' : 'Открыть →'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
@@ -48,7 +49,7 @@ interface TicketDetail {
|
||||
responses: TicketResponse[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
const STATUS_LABELS_RU: Record<string, { text: string; badge: string }> = {
|
||||
open: { text: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { text: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { text: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
@@ -56,18 +57,38 @@ const STATUS_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
closed: { text: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
const STATUS_LABELS_EN: Record<string, { text: string; badge: string }> = {
|
||||
open: { text: 'Open', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { text: 'In Progress', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { text: 'Awaiting Reply', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { text: 'Resolved', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { text: 'Closed', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS_RU: Record<string, { text: string; badge: string }> = {
|
||||
urgent: { text: 'Срочно', badge: 'bg-red-100 text-red-800' },
|
||||
high: { text: 'Высокий', badge: 'bg-orange-100 text-orange-800' },
|
||||
normal: { text: 'Обычный', badge: 'bg-gray-100 text-gray-800' },
|
||||
low: { text: 'Низкий', badge: 'bg-green-100 text-green-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS_EN: Record<string, { text: string; badge: string }> = {
|
||||
urgent: { text: 'Urgent', badge: 'bg-red-100 text-red-800' },
|
||||
high: { text: 'High', badge: 'bg-orange-100 text-orange-800' },
|
||||
normal: { text: 'Normal', badge: 'bg-gray-100 text-gray-800' },
|
||||
low: { text: 'Low', badge: 'bg-green-100 text-green-800' },
|
||||
};
|
||||
|
||||
const TicketDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const STATUS_LABELS = isEn ? STATUS_LABELS_EN : STATUS_LABELS_RU;
|
||||
const PRIORITY_LABELS = isEn ? PRIORITY_LABELS_EN : PRIORITY_LABELS_RU;
|
||||
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
const currentUserId = userData?.user?.id ?? null;
|
||||
@@ -85,7 +106,7 @@ const TicketDetailPage = () => {
|
||||
|
||||
const fetchTicket = async () => {
|
||||
if (!ticketId) {
|
||||
setError('Некорректный идентификатор тикета');
|
||||
setError(isEn ? 'Invalid ticket ID' : 'Некорректный идентификатор тикета');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -99,7 +120,7 @@ const TicketDetailPage = () => {
|
||||
setTicket(payload);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки тикета:', err);
|
||||
setError('Не удалось загрузить тикет');
|
||||
setError(isEn ? 'Failed to load ticket' : 'Не удалось загрузить тикет');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -138,11 +159,11 @@ const TicketDetailPage = () => {
|
||||
|
||||
setReply('');
|
||||
setIsInternalNote(false);
|
||||
addToast('Ответ отправлен', 'success');
|
||||
addToast(isEn ? 'Reply sent' : 'Ответ отправлен', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка отправки ответа:', err);
|
||||
addToast('Не удалось отправить ответ', 'error');
|
||||
addToast(isEn ? 'Failed to send reply' : 'Не удалось отправить ответ', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -151,17 +172,17 @@ const TicketDetailPage = () => {
|
||||
const handleCloseTicket = async () => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const confirmation = window.confirm('Вы уверены, что хотите закрыть тикет?');
|
||||
const confirmation = window.confirm(isEn ? 'Are you sure you want to close this ticket?' : 'Вы уверены, что хотите закрыть тикет?');
|
||||
if (!confirmation) return;
|
||||
|
||||
setStatusProcessing(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/close', { ticketId });
|
||||
addToast('Тикет закрыт', 'success');
|
||||
addToast(isEn ? 'Ticket closed' : 'Тикет закрыт', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка закрытия тикета:', err);
|
||||
addToast('Не удалось закрыть тикет', 'error');
|
||||
addToast(isEn ? 'Failed to close ticket' : 'Не удалось закрыть тикет', 'error');
|
||||
} finally {
|
||||
setStatusProcessing(false);
|
||||
}
|
||||
@@ -173,11 +194,11 @@ const TicketDetailPage = () => {
|
||||
setStatusProcessing(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/status', { ticketId, status });
|
||||
addToast('Статус обновлён', 'success');
|
||||
addToast(isEn ? 'Status updated' : 'Статус обновлён', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка изменения статуса:', err);
|
||||
addToast('Не удалось изменить статус', 'error');
|
||||
addToast(isEn ? 'Failed to update status' : 'Не удалось изменить статус', 'error');
|
||||
} finally {
|
||||
setStatusProcessing(false);
|
||||
}
|
||||
@@ -189,11 +210,11 @@ const TicketDetailPage = () => {
|
||||
setAssigning(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/assign', { ticketId, operatorId: currentUserId });
|
||||
addToast('Тикет назначен на вас', 'success');
|
||||
addToast(isEn ? 'Ticket assigned to you' : 'Тикет назначен на вас', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка назначения тикета:', err);
|
||||
addToast('Не удалось назначить тикет', 'error');
|
||||
addToast(isEn ? 'Failed to assign ticket' : 'Не удалось назначить тикет', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
@@ -217,7 +238,7 @@ const TicketDetailPage = () => {
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="mt-4 text-sm text-gray-600">Загрузка тикета...</p>
|
||||
<p className="mt-4 text-sm text-gray-600">{isEn ? 'Loading ticket...' : 'Загрузка тикета...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -227,10 +248,10 @@ const TicketDetailPage = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-red-600">
|
||||
<h2 className="text-lg font-semibold">Ошибка</h2>
|
||||
<h2 className="text-lg font-semibold">{isEn ? 'Error' : 'Ошибка'}</h2>
|
||||
<p className="mt-2 text-sm">{error}</p>
|
||||
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-red-700">
|
||||
← Вернуться к тикетам
|
||||
← {isEn ? 'Back to tickets' : 'Вернуться к тикетам'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,10 +262,10 @@ const TicketDetailPage = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md rounded-2xl border border-gray-200 bg-white p-6 text-center shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Тикет не найден</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">Возможно, он был удалён или у вас нет доступа.</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'Ticket not found' : 'Тикет не найден'}</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">{isEn ? 'It may have been deleted or you do not have access.' : 'Возможно, он был удалён или у вас нет доступа.'}</p>
|
||||
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-blue-600">
|
||||
← Вернуться к списку
|
||||
← {isEn ? 'Back to list' : 'Вернуться к списку'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +282,7 @@ const TicketDetailPage = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition hover:text-gray-800"
|
||||
>
|
||||
← Назад
|
||||
← {isEn ? 'Back' : 'Назад'}
|
||||
</button>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
@@ -273,7 +294,7 @@ const TicketDetailPage = () => {
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${priorityMeta.badge}`}>
|
||||
{priorityMeta.text}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">Категория: {ticket.category}</span>
|
||||
<span className="text-sm text-gray-500">{isEn ? 'Category' : 'Категория'}: {ticket.category}</span>
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
|
||||
{ticket.assignedOperator.username}
|
||||
@@ -282,9 +303,9 @@ const TicketDetailPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-sm text-gray-600">
|
||||
<span>Создан: {formatDateTime(ticket.createdAt)}</span>
|
||||
<span>Обновлён: {formatDateTime(ticket.updatedAt)}</span>
|
||||
{ticket.closedAt && <span>Закрыт: {formatDateTime(ticket.closedAt)}</span>}
|
||||
<span>{isEn ? 'Created' : 'Создан'}: {formatDateTime(ticket.createdAt)}</span>
|
||||
<span>{isEn ? 'Updated' : 'Обновлён'}: {formatDateTime(ticket.updatedAt)}</span>
|
||||
{ticket.closedAt && <span>{isEn ? 'Closed' : 'Закрыт'}: {formatDateTime(ticket.closedAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,12 +315,14 @@ const TicketDetailPage = () => {
|
||||
|
||||
{ticket.attachments.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Вложенные файлы</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-700">{isEn ? 'Attached Files' : 'Вложенные файлы'}</h3>
|
||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm text-blue-600">
|
||||
{ticket.attachments.map((attachment) => (
|
||||
<li key={attachment.id}>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
|
||||
📎 {attachment.filename}
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100 max-w-full">
|
||||
<span className="truncate max-w-[200px] sm:max-w-[300px] md:max-w-none" title={attachment.filename}>
|
||||
📎 {attachment.filename}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@@ -315,7 +338,7 @@ const TicketDetailPage = () => {
|
||||
disabled={assigning}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{assigning ? 'Назначаю...' : 'Взять в работу'}
|
||||
{assigning ? (isEn ? 'Assigning...' : 'Назначаю...') : (isEn ? 'Take on' : 'Взять в работу')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -328,7 +351,7 @@ const TicketDetailPage = () => {
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-green-200 px-4 py-2 text-sm font-semibold text-green-600 transition hover:border-green-300 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{statusProcessing ? 'Сохранение...' : 'Отметить как решён'}
|
||||
{statusProcessing ? (isEn ? 'Saving...' : 'Сохранение...') : (isEn ? 'Mark as Resolved' : 'Отметить как решён')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -337,7 +360,7 @@ const TicketDetailPage = () => {
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Закрыть тикет
|
||||
{isEn ? 'Close Ticket' : 'Закрыть тикет'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -349,18 +372,18 @@ const TicketDetailPage = () => {
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Возобновить работу
|
||||
{isEn ? 'Reopen' : 'Возобновить работу'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">История общения</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'Conversation History' : 'История общения'}</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{ticket.responses.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
|
||||
Ответов пока нет. Напишите первое сообщение, чтобы ускорить решение.
|
||||
{isEn ? 'No replies yet. Write the first message to speed up resolution.' : 'Ответов пока нет. Напишите первое сообщение, чтобы ускорить решение.'}
|
||||
</p>
|
||||
) : (
|
||||
ticket.responses.map((response) => {
|
||||
@@ -375,15 +398,15 @@ const TicketDetailPage = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{response.author?.username ?? 'Неизвестно'}</span>
|
||||
<span className="font-semibold text-gray-900">{response.author?.username ?? (isEn ? 'Unknown' : 'Неизвестно')}</span>
|
||||
{isResponseOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
Оператор
|
||||
{isEn ? 'Operator' : 'Оператор'}
|
||||
</span>
|
||||
)}
|
||||
{response.isInternal && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-700">
|
||||
Внутренний комментарий
|
||||
{isEn ? 'Internal Comment' : 'Внутренний комментарий'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{formatDateTime(response.createdAt)}</span>
|
||||
@@ -394,8 +417,10 @@ const TicketDetailPage = () => {
|
||||
<ul className="mt-3 flex flex-wrap gap-2 text-sm text-blue-600">
|
||||
{response.attachments.map((attachment) => (
|
||||
<li key={attachment.id}>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
|
||||
📎 {attachment.filename}
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100 max-w-full">
|
||||
<span className="truncate max-w-[200px] sm:max-w-[300px] md:max-w-none" title={attachment.filename}>
|
||||
📎 {attachment.filename}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@@ -410,11 +435,11 @@ const TicketDetailPage = () => {
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Новый ответ</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'New Reply' : 'Новый ответ'}</h2>
|
||||
<textarea
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder="Опишите детали, приложите решение или уточнение..."
|
||||
placeholder={isEn ? 'Describe details, attach solution or clarification...' : 'Опишите детали, приложите решение или уточнение...'}
|
||||
className="mt-3 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
rows={6}
|
||||
/>
|
||||
@@ -427,7 +452,7 @@ const TicketDetailPage = () => {
|
||||
onChange={(event) => setIsInternalNote(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Внутренний комментарий (видно только операторам)
|
||||
{isEn ? 'Internal comment (visible only to operators)' : 'Внутренний комментарий (видно только операторам)'}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -438,7 +463,7 @@ const TicketDetailPage = () => {
|
||||
disabled={sending || reply.length === 0}
|
||||
className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Очистить
|
||||
{isEn ? 'Clear' : 'Очистить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -446,7 +471,7 @@ const TicketDetailPage = () => {
|
||||
disabled={sending || !reply.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить'}
|
||||
{sending ? (isEn ? 'Sending...' : 'Отправка...') : (isEn ? 'Send' : 'Отправить')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
@@ -66,7 +67,7 @@ interface TicketStats {
|
||||
unassigned?: number;
|
||||
}
|
||||
|
||||
const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
const STATUS_DICTIONARY_RU: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
@@ -74,21 +75,41 @@ const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
closed: { label: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
const STATUS_DICTIONARY_EN: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Open', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'In Progress', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Awaiting Reply', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { label: 'Resolved', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { label: 'Closed', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY_RU: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Срочно', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'Высокий', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Обычный', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Низкий', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY_EN: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Urgent', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'High', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Normal', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Low', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
|
||||
|
||||
const TicketsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
|
||||
const STATUS_DICTIONARY = isEn ? STATUS_DICTIONARY_EN : STATUS_DICTIONARY_RU;
|
||||
const PRIORITY_DICTIONARY = isEn ? PRIORITY_DICTIONARY_EN : PRIORITY_DICTIONARY_RU;
|
||||
|
||||
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||
const [meta, setMeta] = useState<TicketListMeta>({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false });
|
||||
const [stats, setStats] = useState<TicketStats>({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
|
||||
@@ -182,29 +203,29 @@ const TicketsPage = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMinutes < 1) return 'только что';
|
||||
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMinutes < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMinutes < 60) return isEn ? `${diffMinutes} min ago` : `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours} h ago` : `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays} d ago` : `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
const statusCards = useMemo(() => {
|
||||
if (isOperator) {
|
||||
return [
|
||||
{ title: 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
{ title: isEn ? 'Open' : 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: isEn ? 'Awaiting Reply' : 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: isEn ? 'Assigned to me' : 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: isEn ? 'Unassigned' : 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ title: 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
{ title: isEn ? 'Active' : 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: isEn ? 'Awaiting Reply' : 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: isEn ? 'Closed' : 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}, [isOperator, stats]);
|
||||
}, [isOperator, stats, isEn]);
|
||||
|
||||
const handleChangePage = (nextPage: number) => {
|
||||
setMeta((prev) => ({ ...prev, page: nextPage }));
|
||||
@@ -215,14 +236,14 @@ const TicketsPage = () => {
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Создавайте обращения и следите за их обработкой в режиме реального времени.</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||
<p className="text-gray-600">{isEn ? 'Create tickets and track their processing in real time.' : 'Создавайте обращения и следите за их обработкой в режиме реального времени.'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
Новый тикет
|
||||
{isEn ? 'New Ticket' : 'Новый тикет'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -240,69 +261,69 @@ const TicketsPage = () => {
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Статус</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Status' : 'Статус'}</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="awaiting_reply">Ожидает ответа</option>
|
||||
<option value="resolved">Решён</option>
|
||||
<option value="closed">Закрыт</option>
|
||||
<option value="all">{isEn ? 'All statuses' : 'Все статусы'}</option>
|
||||
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Категория</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Category' : 'Категория'}</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, category: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="all">{isEn ? 'All categories' : 'Все категории'}</option>
|
||||
<option value="general">{isEn ? 'General' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Приоритет</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Priority' : 'Приоритет'}</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="all">{isEn ? 'All priorities' : 'Все приоритеты'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
</select>
|
||||
</div>
|
||||
{isOperator && (
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Назначение</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Assignment' : 'Назначение'}</label>
|
||||
<select
|
||||
value={filters.assigned}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, assigned: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="me">Мои тикеты</option>
|
||||
<option value="unassigned">Без оператора</option>
|
||||
<option value="others">Назначены другим</option>
|
||||
<option value="all">{isEn ? 'All' : 'Все'}</option>
|
||||
<option value="me">{isEn ? 'My tickets' : 'Мои тикеты'}</option>
|
||||
<option value="unassigned">{isEn ? 'Unassigned' : 'Без оператора'}</option>
|
||||
<option value="others">{isEn ? 'Assigned to others' : 'Назначены другим'}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={isOperator ? 'lg:col-span-4' : 'lg:col-span-6'}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Поиск</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Search' : 'Поиск'}</label>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск по теме или описанию..."
|
||||
placeholder={isEn ? 'Search by subject or description...' : 'Поиск по теме или описанию...'}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
@@ -313,29 +334,29 @@ const TicketsPage = () => {
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка тикетов...</p>
|
||||
<p className="text-sm text-gray-500">{isEn ? 'Loading tickets...' : 'Загрузка тикетов...'}</p>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Тикетов пока нет</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{isEn ? 'No tickets yet' : 'Тикетов пока нет'}</h3>
|
||||
<p className="max-w-md text-sm text-gray-500">
|
||||
Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.
|
||||
{isEn ? 'Create a ticket so the support team can help. We are always here.' : 'Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.'}
|
||||
</p>
|
||||
<Link
|
||||
to="/dashboard/tickets/new"
|
||||
className="rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
Создать первый тикет
|
||||
{isEn ? 'Create first ticket' : 'Создать первый тикет'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden w-full grid-cols-[100px_1fr_160px_160px_160px] gap-4 border-b border-gray-100 px-6 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 lg:grid">
|
||||
<span>ID</span>
|
||||
<span>Тема</span>
|
||||
<span>Статус</span>
|
||||
<span>Приоритет</span>
|
||||
<span>Обновлён</span>
|
||||
<span>{isEn ? 'Subject' : 'Тема'}</span>
|
||||
<span>{isEn ? 'Status' : 'Статус'}</span>
|
||||
<span>{isEn ? 'Priority' : 'Приоритет'}</span>
|
||||
<span>{isEn ? 'Updated' : 'Обновлён'}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{tickets.map((ticket) => {
|
||||
@@ -358,7 +379,7 @@ const TicketsPage = () => {
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{ticket.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700 truncate max-w-[120px]" title={ticket.assignedOperator.username}>
|
||||
{ticket.assignedOperator.username}
|
||||
</span>
|
||||
)}
|
||||
@@ -367,7 +388,7 @@ const TicketsPage = () => {
|
||||
{ticket.responseCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500 truncate max-w-[120px]" title={ticket.user?.username ?? 'Неизвестно'}>
|
||||
{ticket.user?.username ?? 'Неизвестно'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,13 @@ import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
const NewTicketPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
message: '',
|
||||
@@ -19,7 +22,7 @@ const NewTicketPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.title.trim() || !formData.message.trim()) {
|
||||
setError('Заполните все поля');
|
||||
setError(isEn ? 'Please fill in all fields' : 'Заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,12 +33,12 @@ const NewTicketPage: React.FC = () => {
|
||||
const response = await apiClient.post('/api/ticket/create', formData);
|
||||
|
||||
// Перенаправляем на созданный тикет
|
||||
addToast('Тикет создан и отправлен в поддержку', 'success');
|
||||
addToast(isEn ? 'Ticket created and sent to support' : 'Тикет создан и отправлен в поддержку', 'success');
|
||||
navigate(`/dashboard/tickets/${response.data.ticket.id}`);
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания тикета:', err);
|
||||
setError('Не удалось создать тикет. Попробуйте ещё раз.');
|
||||
addToast('Не удалось создать тикет', 'error');
|
||||
setError(isEn ? 'Failed to create ticket. Please try again.' : 'Не удалось создать тикет. Попробуйте ещё раз.');
|
||||
addToast(isEn ? 'Failed to create ticket' : 'Не удалось создать тикет', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -50,12 +53,12 @@ const NewTicketPage: React.FC = () => {
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Назад к тикетам</span>
|
||||
<span>{isEn ? 'Back to tickets' : 'Назад к тикетам'}</span>
|
||||
</Link>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-xl shadow-md p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Создать новый тикет</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEn ? 'Create New Ticket' : 'Создать новый тикет'}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
|
||||
@@ -67,13 +70,13 @@ const NewTicketPage: React.FC = () => {
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Тема <span className="text-red-500">*</span>
|
||||
{isEn ? 'Subject' : 'Тема'} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Кратко опишите вашу проблему"
|
||||
placeholder={isEn ? 'Briefly describe your issue' : 'Кратко опишите вашу проблему'}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
@@ -84,34 +87,34 @@ const NewTicketPage: React.FC = () => {
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Категория
|
||||
{isEn ? 'Category' : 'Категория'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="general">{isEn ? 'General Questions' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Приоритет
|
||||
{isEn ? 'Priority' : 'Приоритет'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,29 +122,29 @@ const NewTicketPage: React.FC = () => {
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Описание <span className="text-red-500">*</span>
|
||||
{isEn ? 'Description' : 'Описание'} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder="Подробно опишите вашу проблему или вопрос..."
|
||||
placeholder={isEn ? 'Describe your issue or question in detail...' : 'Подробно опишите вашу проблему или вопрос...'}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={8}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Минимум 10 символов. Чем подробнее вы опишете проблему, тем быстрее мы сможем помочь.
|
||||
{isEn ? 'Minimum 10 characters. The more details you provide, the faster we can help.' : 'Минимум 10 символов. Чем подробнее вы опишете проблему, тем быстрее мы сможем помочь.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">💡 Советы:</h3>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">💡 {isEn ? 'Tips:' : 'Советы:'}</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• Укажите все детали проблемы</li>
|
||||
<li>• Приложите скриншоты, если возможно</li>
|
||||
<li>• Опишите шаги для воспроизведения ошибки</li>
|
||||
<li>• Среднее время ответа: 2-4 часа</li>
|
||||
<li>• {isEn ? 'Include all details of the issue' : 'Укажите все детали проблемы'}</li>
|
||||
<li>• {isEn ? 'Attach screenshots if possible' : 'Приложите скриншоты, если возможно'}</li>
|
||||
<li>• {isEn ? 'Describe steps to reproduce the error' : 'Опишите шаги для воспроизведения ошибки'}</li>
|
||||
<li>• {isEn ? 'Average response time: 2-4 hours' : 'Среднее время ответа: 2-4 часа'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -151,14 +154,14 @@ const NewTicketPage: React.FC = () => {
|
||||
to="/dashboard/tickets"
|
||||
className="px-6 py-3 text-gray-700 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Отмена
|
||||
{isEn ? 'Cancel' : 'Отмена'}
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending ? 'Создание...' : 'Создать тикет'}
|
||||
{sending ? (isEn ? 'Creating...' : 'Создание...') : (isEn ? 'Create Ticket' : 'Создать тикет')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
250
ospabhost/frontend/src/pages/errors/CloudflareError.tsx
Normal file
250
ospabhost/frontend/src/pages/errors/CloudflareError.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface CloudflareErrorProps {
|
||||
errorCode: string;
|
||||
title: string;
|
||||
description: string;
|
||||
whatHappened: string;
|
||||
whatCanIDo?: string;
|
||||
rayId?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
const CloudflareError: React.FC<CloudflareErrorProps> = ({
|
||||
errorCode,
|
||||
title,
|
||||
description,
|
||||
whatHappened,
|
||||
whatCanIDo,
|
||||
rayId,
|
||||
ip,
|
||||
}) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const currentTime = new Date().toUTCString();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header Bar */}
|
||||
<div className="bg-[#f38020] h-2" />
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Error Header */}
|
||||
<div className="border-b-4 border-[#f38020] pb-6 mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h1 className="text-6xl font-bold text-gray-800">
|
||||
{isEn ? 'Error' : 'Ошибка'} {errorCode}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
||||
{rayId && (
|
||||
<>
|
||||
<span className="font-mono">Ray ID: {rayId}</span>
|
||||
<span>•</span>
|
||||
</>
|
||||
)}
|
||||
<span>{currentTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Title */}
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8">{title}</h2>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* What happened */}
|
||||
<div className="bg-gray-50 rounded-xl p-6 border-l-4 border-[#f38020]">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-[#f38020]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{isEn ? 'What happened?' : 'Что произошло?'}
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{whatHappened}</p>
|
||||
</div>
|
||||
|
||||
{/* What can I do */}
|
||||
{whatCanIDo && (
|
||||
<div className="bg-gray-50 rounded-xl p-6 border-l-4 border-blue-500">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
{isEn ? 'What can I do?' : 'Что я могу сделать?'}
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{whatCanIDo}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-8 p-6 bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl">
|
||||
<p className="text-gray-600 text-sm">{description}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{rayId && (
|
||||
<span className="mr-4">
|
||||
ospab.host Ray ID: <span className="font-mono">{rayId}</span>
|
||||
</span>
|
||||
)}
|
||||
{ip && (
|
||||
<span>
|
||||
{isEn ? 'Your IP:' : 'Ваш IP:'}{' '}
|
||||
<button
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={() => alert(ip)}
|
||||
>
|
||||
{isEn ? 'Click to reveal' : 'Нажмите для показа'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>{isEn ? 'Performance & security by' : 'Производительность и безопасность'}</span>
|
||||
<span className="font-bold text-[#f38020]">ospab.host</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="mt-6 text-center">
|
||||
<span className="text-sm text-gray-500">
|
||||
{isEn ? 'Was this page helpful?' : 'Была ли эта страница полезной?'}
|
||||
</span>
|
||||
<div className="inline-flex gap-2 ml-4">
|
||||
<button className="px-4 py-1 border border-gray-300 rounded hover:bg-gray-100 text-sm">
|
||||
{isEn ? 'Yes' : 'Да'}
|
||||
</button>
|
||||
<button className="px-4 py-1 border border-gray-300 rounded hover:bg-gray-100 text-sm">
|
||||
{isEn ? 'No' : 'Нет'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudflareError;
|
||||
|
||||
// Pre-configured error pages
|
||||
export const Error1000: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="1000"
|
||||
title={isEn ? 'DNS points to prohibited IP' : 'DNS указывает на запрещённый IP'}
|
||||
description={isEn
|
||||
? 'Please see https://ospab.host/docs/errors/1000 for more details.'
|
||||
: 'Подробнее см. https://ospab.host/docs/errors/1000'}
|
||||
whatHappened={isEn
|
||||
? "You've requested a page on a website that is on the ospab.host network. Unfortunately, it is resolving to an IP address that is creating a conflict within the system."
|
||||
: 'Вы запросили страницу на сайте, который находится в сети ospab.host. К сожалению, он разрешается в IP-адрес, который создаёт конфликт в системе.'}
|
||||
whatCanIDo={isEn
|
||||
? 'If you are the owner of this website: you should login to ospab.host dashboard and change the DNS A records to resolve to a different IP address.'
|
||||
: 'Если вы владелец этого сайта: войдите в панель управления ospab.host и измените DNS A-записи, чтобы они указывали на другой IP-адрес.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error502: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="502"
|
||||
title={isEn ? 'Bad Gateway' : 'Плохой шлюз'}
|
||||
description={isEn
|
||||
? 'The web server reported a bad gateway error.'
|
||||
: 'Веб-сервер сообщил об ошибке шлюза.'}
|
||||
whatHappened={isEn
|
||||
? 'The web server is not returning a proper response. This could be caused by the origin server being down or overloaded.'
|
||||
: 'Веб-сервер не возвращает корректный ответ. Это может быть вызвано недоступностью или перегрузкой сервера-источника.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try refreshing the page in a few minutes. If you are the site owner, check your server logs for errors.'
|
||||
: 'Попробуйте обновить страницу через несколько минут. Если вы владелец сайта, проверьте логи сервера на наличие ошибок.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error503: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="503"
|
||||
title={isEn ? 'Service Temporarily Unavailable' : 'Сервис временно недоступен'}
|
||||
description={isEn
|
||||
? 'The server is temporarily unable to handle the request.'
|
||||
: 'Сервер временно не может обработать запрос.'}
|
||||
whatHappened={isEn
|
||||
? 'The origin web server is currently experiencing high load or is undergoing maintenance.'
|
||||
: 'Исходный веб-сервер в настоящее время испытывает высокую нагрузку или находится на техническом обслуживании.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try refreshing the page in a few minutes. If the problem persists, please contact support.'
|
||||
: 'Попробуйте обновить страницу через несколько минут. Если проблема сохраняется, обратитесь в поддержку.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error504: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="504"
|
||||
title={isEn ? 'Gateway Timeout' : 'Превышено время ожидания'}
|
||||
description={isEn
|
||||
? 'The gateway did not receive a timely response from the upstream server.'
|
||||
: 'Шлюз не получил своевременный ответ от вышестоящего сервера.'}
|
||||
whatHappened={isEn
|
||||
? 'The origin web server did not respond within the expected time. This could be caused by slow processing or network issues.'
|
||||
: 'Исходный веб-сервер не ответил в ожидаемое время. Это может быть вызвано медленной обработкой или сетевыми проблемами.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try again later. If you are the site owner, consider increasing timeout values or optimizing your server performance.'
|
||||
: 'Попробуйте позже. Если вы владелец сайта, рассмотрите возможность увеличения таймаутов или оптимизации производительности сервера.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error520: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="520"
|
||||
title={isEn ? 'Web Server Returned an Unknown Error' : 'Веб-сервер вернул неизвестную ошибку'}
|
||||
description={isEn
|
||||
? 'The origin web server returned an empty, unknown, or unexpected response.'
|
||||
: 'Исходный веб-сервер вернул пустой, неизвестный или неожиданный ответ.'}
|
||||
whatHappened={isEn
|
||||
? 'The origin web server returned an unexpected or malformed response that cannot be processed.'
|
||||
: 'Исходный веб-сервер вернул неожиданный или некорректный ответ, который не может быть обработан.'}
|
||||
whatCanIDo={isEn
|
||||
? 'If you are the site owner, check your server configuration and ensure your application is responding correctly.'
|
||||
: 'Если вы владелец сайта, проверьте конфигурацию сервера и убедитесь, что ваше приложение отвечает корректно.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
37
ospabhost/frontend/src/pages/errors/NetworkError.tsx
Normal file
37
ospabhost/frontend/src/pages/errors/NetworkError.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ErrorPage from '../../components/ErrorPage';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
const NetworkError: React.FC = () => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [hostname, setHostname] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setHostname(window.location.hostname);
|
||||
}, []);
|
||||
|
||||
const description = isEn
|
||||
? `You have reached a service address (${hostname || 'unknown'}). This page is not available for public access.`
|
||||
: `Вы попали на служебный адрес (${hostname || 'неизвестен'}). Эта страница недоступна для публичного доступа.`;
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
code="1000"
|
||||
title={isEn ? 'Service Address' : 'Служебный адрес'}
|
||||
description={description}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
color="purple"
|
||||
showLoginButton={false}
|
||||
showBackButton={false}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkError;
|
||||
68
ospabhost/frontend/src/pages/errors/index.tsx
Normal file
68
ospabhost/frontend/src/pages/errors/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import CloudflareError, { Error1000, Error502, Error503, Error504, Error520 } from './CloudflareError';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
const generateRayId = () => {
|
||||
const chars = 'abcdef0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return `${result.slice(0, 8)}-${result.slice(8, 12)}-${result.slice(12)}`;
|
||||
};
|
||||
|
||||
const ErrorPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [rayId] = useState(() => generateRayId());
|
||||
const [userIp, setUserIp] = useState<string | undefined>();
|
||||
|
||||
const errorCode = searchParams.get('code') || '500';
|
||||
const customMessage = searchParams.get('message');
|
||||
|
||||
useEffect(() => {
|
||||
// Try to get user IP (optional)
|
||||
fetch('https://api.ipify.org?format=json')
|
||||
.then(res => res.json())
|
||||
.then(data => setUserIp(data.ip))
|
||||
.catch(() => setUserIp(undefined));
|
||||
}, []);
|
||||
|
||||
// Route to specific error pages
|
||||
switch (errorCode) {
|
||||
case '1000':
|
||||
return <Error1000 rayId={rayId} ip={userIp} />;
|
||||
case '502':
|
||||
return <Error502 rayId={rayId} ip={userIp} />;
|
||||
case '503':
|
||||
return <Error503 rayId={rayId} ip={userIp} />;
|
||||
case '504':
|
||||
return <Error504 rayId={rayId} ip={userIp} />;
|
||||
case '520':
|
||||
return <Error520 rayId={rayId} ip={userIp} />;
|
||||
default:
|
||||
// Generic error
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode={errorCode}
|
||||
title={customMessage || (isEn ? 'An Error Occurred' : 'Произошла ошибка')}
|
||||
description={isEn
|
||||
? 'If this problem persists, please contact our support team.'
|
||||
: 'Если проблема сохраняется, обратитесь в нашу службу поддержки.'}
|
||||
whatHappened={isEn
|
||||
? 'Something went wrong while processing your request. This could be a temporary issue.'
|
||||
: 'Что-то пошло не так при обработке вашего запроса. Это может быть временная проблема.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try refreshing the page or going back to the homepage. If the issue persists, please contact support.'
|
||||
: 'Попробуйте обновить страницу или вернуться на главную. Если проблема сохраняется, обратитесь в поддержку.'}
|
||||
rayId={rayId}
|
||||
ip={userIp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -1,65 +1,86 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaCloud, FaShieldAlt, FaDatabase } from 'react-icons/fa';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const HomePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-800">
|
||||
<div className="min-h-screen bg-gray-50 text-gray-800 overflow-hidden">
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-gradient-to-b from-blue-100 to-white pt-24 pb-32">
|
||||
<div className="container mx-auto text-center px-4">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900">
|
||||
Облачное хранилище <br /> для ваших данных
|
||||
<section className="relative bg-white pt-24 pb-32 overflow-hidden">
|
||||
{/* Light gradient background */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-white to-indigo-50" />
|
||||
</div>
|
||||
|
||||
{/* Soft mesh gradient - only blue tones */}
|
||||
<div className="absolute top-0 left-0 w-[600px] h-[600px] bg-blue-400/15 rounded-full blur-[100px] -translate-x-1/4 -translate-y-1/4" />
|
||||
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-indigo-400/15 rounded-full blur-[100px] translate-x-1/4 translate-y-1/4" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-sky-200/30 rounded-full blur-[80px]" />
|
||||
|
||||
<div className="container mx-auto text-center px-4 relative z-10">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900 animate-fade-in-down">
|
||||
{t('home.hero.title')}
|
||||
</h1>
|
||||
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-700">
|
||||
S3-совместимое хранилище с высокой доступностью и надежностью. Храните файлы, резервные копии и медиа-контент.
|
||||
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-600 animate-fade-in-up animation-delay-200" style={{ opacity: 0 }}>
|
||||
{t('home.hero.description')}
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<div className="mt-10 flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4 animate-fade-in-up animation-delay-400" style={{ opacity: 0 }}>
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-8 py-4 rounded-full text-white font-bold text-lg transition-transform transform hover:scale-105 shadow-lg bg-ospab-primary hover:bg-ospab-accent"
|
||||
to={localePath('/register')}
|
||||
className="px-8 py-4 rounded-full text-white font-bold text-lg shadow-lg shadow-blue-500/30 bg-ospab-primary hover:bg-blue-700 btn-hover"
|
||||
>
|
||||
Начать бесплатно
|
||||
{t('home.hero.cta')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-8 py-4 rounded-full text-gray-800 font-bold text-lg border-2 border-gray-400 transition-colors hover:bg-gray-200 hover:border-gray-300"
|
||||
to={localePath('/login')}
|
||||
className="px-8 py-4 rounded-full text-gray-700 font-bold text-lg border-2 border-gray-300 hover:bg-gray-100 hover:border-gray-400 btn-hover"
|
||||
>
|
||||
Войти в аккаунт
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-4">
|
||||
<section className="py-20 px-4 bg-white">
|
||||
<div className="container mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 text-gray-900">Наши возможности</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 text-gray-900 animate-fade-in-up">{t('home.features.title')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 transform hover:scale-105">
|
||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.1s' }}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<FaDatabase className="text-5xl text-blue-500" />
|
||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
||||
<FaDatabase className="text-4xl text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">S3 API</h3>
|
||||
<p className="mt-2 text-center text-gray-700">
|
||||
Полная совместимость с Amazon S3 API. Используйте привычные инструменты и SDK.
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.s3Compatible.title')}</h3>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
{t('home.features.s3Compatible.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 transform hover:scale-105">
|
||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.25s' }}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<FaCloud className="text-5xl text-blue-500" />
|
||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
||||
<FaCloud className="text-4xl text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">Масштабируемость</h3>
|
||||
<p className="mt-2 text-center text-gray-700">
|
||||
Неограниченное хранилище. Платите только за используемое пространство.
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.speed.title')}</h3>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
{t('home.features.speed.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 transform hover:scale-105">
|
||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.4s' }}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<FaShieldAlt className="text-5xl text-blue-500" />
|
||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
||||
<FaShieldAlt className="text-4xl text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">Надежность и безопасность</h3>
|
||||
<p className="mt-2 text-center text-gray-700">
|
||||
Шифрование данных, резервное копирование и высокая доступность 99.9%.
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.reliability.title')}</h3>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
{t('home.features.reliability.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,20 +88,29 @@ const HomePage = () => {
|
||||
</section>
|
||||
|
||||
{/* Call to Action Section */}
|
||||
<section className="bg-gray-800 py-20 px-4 text-white text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold leading-tight">
|
||||
Готовы начать?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg md:text-xl max-w-2xl mx-auto text-gray-400">
|
||||
Присоединяйтесь к разработчикам, которые доверяют нам свои данные.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-8 py-4 rounded-full text-white font-bold text-lg transition-transform transform hover:scale-105 shadow-lg bg-ospab-primary hover:bg-ospab-accent"
|
||||
>
|
||||
Начать бесплатно
|
||||
</Link>
|
||||
<section className="bg-gradient-to-r from-gray-900 to-gray-800 py-20 px-4 text-white text-center relative overflow-hidden">
|
||||
{/* Subtle pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='1' fill-rule='evenodd'%3E%3Ccircle cx='3' cy='3' r='3'/%3E%3Ccircle cx='13' cy='13' r='3'/%3E%3C/g%3E%3C/svg%3E")`
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl md:text-5xl font-bold leading-tight animate-fade-in-up">
|
||||
{t('home.cta.title')}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg md:text-xl max-w-2xl mx-auto text-gray-400 animate-fade-in-up animation-delay-200" style={{ opacity: 0 }}>
|
||||
{t('home.cta.description')}
|
||||
</p>
|
||||
<div className="mt-8 animate-fade-in-up animation-delay-400" style={{ opacity: 0 }}>
|
||||
<Link
|
||||
to={localePath('/register')}
|
||||
className="inline-block px-8 py-4 rounded-full text-white font-bold text-lg shadow-lg bg-blue-600 hover:bg-blue-700 btn-hover"
|
||||
>
|
||||
{t('home.hero.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { API_URL } from '../config/api';
|
||||
import QRLogin from '../components/QRLogin';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [loginMethod, setLoginMethod] = useState<'password' | 'qr'>('password');
|
||||
@@ -18,36 +20,48 @@ const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoggedIn } = useAuth();
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||
|
||||
// Если уже авторизован — редирект на dashboard
|
||||
// Redirect if logged in
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
// Обработка OAuth токена из URL
|
||||
// Handle OAuth token from URL & QR param
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
const qrParam = params.get('qr');
|
||||
|
||||
if (token) {
|
||||
login(token);
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
setError('Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
}, [isLoggedIn, navigate, location, login]);
|
||||
|
||||
if (qrParam === '1' || qrParam === 'true') {
|
||||
setLoginMethod('qr');
|
||||
// allow QR component to generate immediately
|
||||
}
|
||||
}, [isLoggedIn, navigate, location, login, localePath, locale]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!turnstileToken) {
|
||||
setError('Пожалуйста, подтвердите, что вы не робот.');
|
||||
setError(locale === 'en'
|
||||
? 'Please confirm you are not a robot.'
|
||||
: 'Пожалуйста, подтвердите, что вы не робот.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,22 +73,24 @@ const LoginPage = () => {
|
||||
turnstileToken: turnstileToken,
|
||||
});
|
||||
login(response.data.token);
|
||||
// Возврат на исходную страницу, если был редирект
|
||||
// Return to original page if redirected
|
||||
type LocationState = { from?: { pathname?: string } };
|
||||
const state = location.state as LocationState | null;
|
||||
const from = state?.from?.pathname || '/dashboard';
|
||||
const from = state?.from?.pathname || localePath('/dashboard');
|
||||
navigate(from);
|
||||
} catch (err) {
|
||||
// Сброс капчи при ошибке
|
||||
// Reset captcha on error
|
||||
if (turnstileRef.current) {
|
||||
turnstileRef.current.reset();
|
||||
}
|
||||
setTurnstileToken(null);
|
||||
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
setError(err.response.data.message || 'Неизвестная ошибка входа.');
|
||||
setError(err.response.data.message || (locale === 'en' ? 'Unknown login error.' : 'Неизвестная ошибка входа.'));
|
||||
} else {
|
||||
setError('Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
setError(locale === 'en'
|
||||
? 'Network error. Please try again later.'
|
||||
: 'Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -88,9 +104,9 @@ const LoginPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">Вход в аккаунт</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.login.title')}</h1>
|
||||
|
||||
{/* Переключатель метода входа */}
|
||||
{/* Login method toggle */}
|
||||
<div className="flex mb-6 bg-gray-100 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,7 +117,7 @@ const LoginPage = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Пароль
|
||||
{t('auth.login.password')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,7 +128,7 @@ const LoginPage = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
QR-код
|
||||
{locale === 'en' ? 'QR Code' : 'QR-код'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +139,7 @@ const LoginPage = () => {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Электронная почта"
|
||||
placeholder={t('auth.login.email')}
|
||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -132,7 +148,7 @@ const LoginPage = () => {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Пароль"
|
||||
placeholder={t('auth.login.password')}
|
||||
className="w-full px-5 py-3 mb-6 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -146,7 +162,9 @@ const LoginPage = () => {
|
||||
onSuccess={(token: string) => setTurnstileToken(token)}
|
||||
onError={() => {
|
||||
setTurnstileToken(null);
|
||||
setError('Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||
setError(locale === 'en'
|
||||
? 'Captcha loading error. Try refreshing the page.'
|
||||
: 'Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||
}}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
@@ -157,7 +175,9 @@ const LoginPage = () => {
|
||||
disabled={isLoading || !turnstileToken}
|
||||
className="w-full px-5 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Входим...' : 'Войти'}
|
||||
{isLoading
|
||||
? (locale === 'en' ? 'Signing in...' : 'Входим...')
|
||||
: t('auth.login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
@@ -167,17 +187,17 @@ const LoginPage = () => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<QRLogin onSuccess={() => navigate('/dashboard')} />
|
||||
<QRLogin onSuccess={() => navigate(localePath('/dashboard'))} />
|
||||
)}
|
||||
|
||||
{/* Социальные сети */}
|
||||
{/* Social login */}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Или войти через</span>
|
||||
<span className="px-2 bg-white text-gray-500">{t('auth.login.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +206,7 @@ const LoginPage = () => {
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label="Войти через Google"
|
||||
aria-label={locale === 'en' ? 'Sign in with Google' : 'Войти через Google'}
|
||||
>
|
||||
<img src="/google.png" alt="Google" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -195,7 +215,7 @@ const LoginPage = () => {
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label="Войти через GitHub"
|
||||
aria-label={locale === 'en' ? 'Sign in with GitHub' : 'Войти через GitHub'}
|
||||
>
|
||||
<img src="/github.png" alt="GitHub" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -204,7 +224,7 @@ const LoginPage = () => {
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('yandex')}
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label="Войти через Yandex"
|
||||
aria-label={locale === 'en' ? 'Sign in with Yandex' : 'Войти через Yandex'}
|
||||
>
|
||||
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -213,9 +233,9 @@ const LoginPage = () => {
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-gray-600">
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/register" className="text-ospab-primary font-bold hover:underline">
|
||||
Зарегистрироваться
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link to={localePath('/register')} className="text-ospab-primary font-bold hover:underline">
|
||||
{t('auth.register.title')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React from 'react';
|
||||
import PageTmpl from '../components/pagetempl';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
const Privacy: React.FC = () => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<PageTmpl>
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">Политика конфиденциальности ospab.host</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||
{isEn ? 'Privacy Policy ospab.host' : 'Политика конфиденциальности ospab.host'}
|
||||
</h1>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-gray-600 mb-6">
|
||||
|
||||
@@ -15,6 +15,8 @@ const QRLoginPage = () => {
|
||||
const [status, setStatus] = useState<'loading' | 'confirm' | 'success' | 'error' | 'expired'>('loading');
|
||||
const [message, setMessage] = useState('Проверка QR-кода...');
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
const [remaining, setRemaining] = useState<number>(0);
|
||||
const [requestInfo, setRequestInfo] = useState<{ ip?: string; ua?: string } | null>(null);
|
||||
const code = searchParams.get('code');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,6 +26,30 @@ const QRLoginPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const updateRemaining = async () => {
|
||||
try {
|
||||
const resp = await apiClient.get(`/api/qr-auth/status/${code}`);
|
||||
if (typeof resp.data.expiresIn === 'number') {
|
||||
setRemaining(Math.max(0, Math.ceil(resp.data.expiresIn)));
|
||||
}
|
||||
// Set request info if provided
|
||||
if (resp.data.ipAddress || resp.data.userAgent) {
|
||||
setRequestInfo({ ip: resp.data.ipAddress, ua: resp.data.userAgent });
|
||||
}
|
||||
if (resp.data.status === 'expired') {
|
||||
setStatus('expired');
|
||||
setMessage('QR-код истёк');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении remaining:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
@@ -50,12 +76,31 @@ const QRLoginPage = () => {
|
||||
setUserData(userResponse.data.user);
|
||||
setStatus('confirm');
|
||||
setMessage('Подтвердите вход на новом устройстве');
|
||||
|
||||
// Start countdown for confirmation page
|
||||
await updateRemaining();
|
||||
countdownTimer = setInterval(async () => {
|
||||
await updateRemaining();
|
||||
// decrease visible counter only if updateRemaining didn't set a new value
|
||||
setRemaining((prev: number) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
setStatus('expired');
|
||||
setMessage('QR-код истёк');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки авторизации:', error);
|
||||
|
||||
if (isAxiosError(error) && error.response?.status === 401) {
|
||||
setStatus('error');
|
||||
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
|
||||
} else if (isAxiosError(error) && error.response?.status === 500) {
|
||||
setStatus('error');
|
||||
setMessage('Серверная ошибка при проверке QR-кода');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('Ошибка проверки авторизации');
|
||||
@@ -64,6 +109,10 @@ const QRLoginPage = () => {
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
return () => {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
@@ -102,10 +151,6 @@ const QRLoginPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
@@ -161,30 +206,61 @@ const QRLoginPage = () => {
|
||||
|
||||
{status === 'confirm' && userData && (
|
||||
<div className="mb-6">
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-4">
|
||||
<p className="text-gray-600 mb-2">Войти на новом устройстве как:</p>
|
||||
<p className="text-xl font-bold text-gray-900">{userData.username}</p>
|
||||
<p className="text-sm text-gray-500">{userData.email}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
Это вы пытаетесь войти? Подтвердите вход на компьютере
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
{/* Device info */}
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
<div className="mb-2">Детали запроса:</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-gray-100 text-xs text-gray-700">
|
||||
<div>IP: <span className="font-medium">{requestInfo?.ip ?? '—'}</span></div>
|
||||
<div className="mt-1">Device: <span className="font-medium">{requestInfo?.ua ?? '—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-friendly confirmation with timer */}
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-600 text-sm mb-3">{remaining > 0 ? `Осталось времени: ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}` : 'QR-код истёк'}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiClient.post('/api/qr-auth/reject', { code });
|
||||
setStatus('error');
|
||||
setMessage('Вход отклонён');
|
||||
} catch (err) {
|
||||
console.error('Ошибка отклонения:', err);
|
||||
setMessage('Не удалось отклонить вход');
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-4 rounded-lg font-medium transition-colors duration-200 text-sm"
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={remaining <= 0}
|
||||
className={`flex-1 ${remaining > 0 ? 'bg-blue-500 hover:bg-blue-600 text-white' : 'bg-gray-200 text-gray-500 cursor-not-allowed'} px-6 py-4 rounded-lg font-medium transition-colors duration-200 text-sm`}
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remaining <= 0 && (
|
||||
<div className="mt-3 text-center">
|
||||
<button
|
||||
onClick={() => window.open('/login?qr=1', '_blank')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Сгенерировать QR заново (открыть страницу входа)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
@@ -6,11 +6,16 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import useAuth from '../context/useAuth';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
import { validateEmail } from '../utils/emailValidation';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const { addToast } = useToast();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -19,10 +24,36 @@ const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||
|
||||
// Обработка OAuth токена из URL
|
||||
// Email validation on blur
|
||||
const handleEmailBlur = useCallback(() => {
|
||||
if (!email.trim()) {
|
||||
setEmailError(null);
|
||||
setEmailSuggestion(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validateEmail(email, locale);
|
||||
setEmailError(result.isValid ? null : result.error ?? null);
|
||||
setEmailSuggestion(result.suggestion ?? null);
|
||||
}, [email, locale]);
|
||||
|
||||
// Apply email suggestion
|
||||
const applySuggestion = useCallback(() => {
|
||||
if (emailSuggestion) {
|
||||
const match = emailSuggestion.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
|
||||
if (match) {
|
||||
setEmail(match[1]);
|
||||
setEmailSuggestion(null);
|
||||
}
|
||||
}
|
||||
}, [emailSuggestion]);
|
||||
|
||||
// Handle OAuth token from URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
@@ -30,48 +61,57 @@ const RegisterPage = () => {
|
||||
|
||||
if (token) {
|
||||
login(token);
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
setError('Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
}, [location, login, navigate]);
|
||||
}, [location, login, navigate, localePath, locale]);
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(''); // Очищаем предыдущие ошибки
|
||||
setError('');
|
||||
|
||||
// Validate email before submit
|
||||
const emailValidation = validateEmail(email, locale);
|
||||
if (!emailValidation.isValid) {
|
||||
setEmailError(emailValidation.error ?? t('auth.register.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!turnstileToken) {
|
||||
setError('Пожалуйста, подтвердите, что вы не робот.');
|
||||
setError(t('auth.register.captchaRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/api/auth/register`, {
|
||||
await axios.post(`${API_URL}/api/auth/register`, {
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
turnstileToken: turnstileToken,
|
||||
});
|
||||
|
||||
addToast('Регистрация прошла успешно! Теперь вы можете войти.', 'success');
|
||||
navigate('/login');
|
||||
addToast(t('auth.register.success'), 'success');
|
||||
navigate(localePath('/login'));
|
||||
|
||||
} catch (err) {
|
||||
// Сброс капчи при ошибке
|
||||
// Reset captcha on error
|
||||
if (turnstileRef.current) {
|
||||
turnstileRef.current.reset();
|
||||
}
|
||||
setTurnstileToken(null);
|
||||
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
const errorMsg = err.response.data.message || 'Неизвестная ошибка регистрации.';
|
||||
const errorMsg = err.response.data.message || t('auth.register.unknownError');
|
||||
setError(errorMsg);
|
||||
} else {
|
||||
setError('Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
setError(t('auth.register.networkError'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -85,27 +125,50 @@ const RegisterPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">Регистрация</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.register.title')}</h1>
|
||||
<form onSubmit={handleRegister}>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Имя пользователя"
|
||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Электронная почта"
|
||||
placeholder={t('auth.register.usernamePlaceholder')}
|
||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
/>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setEmailError(null);
|
||||
setEmailSuggestion(null);
|
||||
}}
|
||||
onBlur={handleEmailBlur}
|
||||
placeholder={t('auth.register.emailPlaceholder')}
|
||||
className={`w-full px-5 py-3 border rounded-full focus:outline-none focus:ring-2 ${
|
||||
emailError
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-ospab-primary'
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="mt-1 text-sm text-red-500 text-left px-3">{emailError}</p>
|
||||
)}
|
||||
{emailSuggestion && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySuggestion}
|
||||
className="mt-1 text-sm text-blue-600 hover:text-blue-800 text-left px-3 cursor-pointer"
|
||||
>
|
||||
{emailSuggestion}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Пароль"
|
||||
placeholder={t('auth.register.passwordPlaceholder')}
|
||||
className="w-full px-5 py-3 mb-6 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -119,7 +182,7 @@ const RegisterPage = () => {
|
||||
onSuccess={(token: string) => setTurnstileToken(token)}
|
||||
onError={() => {
|
||||
setTurnstileToken(null);
|
||||
setError('Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||
setError(t('auth.register.captchaError'));
|
||||
}}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
@@ -127,24 +190,24 @@ const RegisterPage = () => {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !turnstileToken}
|
||||
disabled={isLoading || !turnstileToken || !!emailError}
|
||||
className="w-full px-5 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Регистрируем...' : 'Зарегистрироваться'}
|
||||
{isLoading ? t('auth.register.loading') : t('auth.register.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Социальные сети */}
|
||||
{/* Social networks */}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Или зарегистрироваться через</span>
|
||||
<span className="px-2 bg-white text-gray-500">{t('auth.register.orRegisterWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,9 +248,9 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-gray-600">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to="/login" className="text-ospab-primary font-bold hover:underline">
|
||||
Войти
|
||||
{t('auth.register.haveAccount')}{' '}
|
||||
<Link to={localePath('/login')} className="text-ospab-primary font-bold hover:underline">
|
||||
{t('auth.register.loginLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from 'react-icons/fa';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
type StoragePlanDto = {
|
||||
id: number;
|
||||
@@ -35,23 +37,33 @@ type DecoratedPlan = StoragePlanDto & {
|
||||
};
|
||||
|
||||
const TIER_LABELS = ['Developer', 'Team', 'Scale', 'Enterprise'];
|
||||
const BASE_FEATURES = [
|
||||
'S3-совместимый API и совместимость с AWS SDK',
|
||||
'Развёртывание в регионе ru-central-1',
|
||||
'Версионирование и presigned URL',
|
||||
'Управление доступом через Access Key/Secret Key',
|
||||
'Уведомления и мониторинг в панели клиента'
|
||||
];
|
||||
|
||||
const formatMetric = (value: number, suffix: string) => `${value.toLocaleString('ru-RU')} ${suffix}`;
|
||||
|
||||
const S3PlansPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
const [plans, setPlans] = useState<StoragePlanDto[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectingPlan, setSelectingPlan] = useState<string | null>(null);
|
||||
|
||||
const BASE_FEATURES = locale === 'en' ? [
|
||||
'S3-compatible API and AWS SDK support',
|
||||
'Deployment in ru-central-1 region',
|
||||
'Versioning and presigned URLs',
|
||||
'Access management via Access Key/Secret Key',
|
||||
'Notifications and monitoring in client panel'
|
||||
] : [
|
||||
'S3-совместимый API и совместимость с AWS SDK',
|
||||
'Развёртывание в регионе ru-central-1',
|
||||
'Версионирование и presigned URL',
|
||||
'Управление доступом через Access Key/Secret Key',
|
||||
'Уведомления и мониторинг в панели клиента'
|
||||
];
|
||||
|
||||
const formatMetric = (value: number, suffix: string) =>
|
||||
`${value.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} ${suffix}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -61,14 +73,14 @@ const S3PlansPage = () => {
|
||||
setError(null);
|
||||
const response = await fetch(`${API_URL}/api/storage/plans`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить тарифы');
|
||||
throw new Error(locale === 'en' ? 'Failed to load plans' : 'Не удалось загрузить тарифы');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!cancelled) {
|
||||
setPlans(Array.isArray(data?.plans) ? data.plans : []);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка загрузки тарифов';
|
||||
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Error loading plans' : 'Ошибка загрузки тарифов');
|
||||
if (!cancelled) {
|
||||
setError(message);
|
||||
}
|
||||
@@ -150,9 +162,9 @@ const S3PlansPage = () => {
|
||||
if (!cartId) {
|
||||
throw new Error('Ответ сервера без идентификатора корзины');
|
||||
}
|
||||
navigate(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`);
|
||||
navigate(localePath(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось начать оплату';
|
||||
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Failed to start payment' : 'Не удалось начать оплату');
|
||||
setError(message);
|
||||
} finally {
|
||||
setSelectingPlan(null);
|
||||
@@ -168,21 +180,22 @@ const S3PlansPage = () => {
|
||||
<span>S3 Object Storage</span>
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-6 text-gray-900">
|
||||
Прозрачные тарифы для любого объёма
|
||||
{locale === 'en' ? 'Transparent pricing for any volume' : 'Прозрачные тарифы для любого объёма'}
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-600 max-w-3xl mx-auto mb-8">
|
||||
Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера,
|
||||
с включённым трафиком, запросами и приоритетной поддержкой.
|
||||
{locale === 'en'
|
||||
? 'Pay only for the resources you need. 12 ready-made plans for teams of any size, with included traffic, requests and priority support.'
|
||||
: 'Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера, с включённым трафиком, запросами и приоритетной поддержкой.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
||||
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s сеть
|
||||
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s {locale === 'en' ? 'network' : 'сеть'}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
||||
<FaLock className="text-emerald-500" /> AES-256 at-rest
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
||||
<FaInfinity className="text-purple-500" /> S3-совместимый API
|
||||
<FaInfinity className="text-purple-500" /> {locale === 'en' ? 'S3-compatible API' : 'S3-совместимый API'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,27 +208,33 @@ const S3PlansPage = () => {
|
||||
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center mb-4">
|
||||
<FaBolt className="text-2xl text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Готовность к нагрузке</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'Load Ready' : 'Готовность к нагрузке'}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Единая платформа на NVMe с автоматическим масштабированием, CDN-интеграцией и кросс-региональной репликацией.
|
||||
{locale === 'en'
|
||||
? 'Unified NVMe platform with auto-scaling, CDN integration and cross-region replication.'
|
||||
: 'Единая платформа на NVMe с автоматическим масштабированием, CDN-интеграцией и кросс-региональной репликацией.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<div className="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<FaShieldAlt className="text-2xl text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Безопасность по умолчанию</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'Security by Default' : 'Безопасность по умолчанию'}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
3 копии данных, IAM роли, шифрование in-transit и at-rest, audit логи, Object Lock и политики хранения.
|
||||
{locale === 'en'
|
||||
? '3 data copies, IAM roles, in-transit and at-rest encryption, audit logs, Object Lock and retention policies.'
|
||||
: '3 копии данных, IAM роли, шифрование in-transit и at-rest, audit логи, Object Lock и политики хранения.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<FaCloud className="text-2xl text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Совместимость с AWS SDK</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'AWS SDK Compatibility' : 'Совместимость с AWS SDK'}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Полный S3 API, поддержка AWS CLI, Terraform, rclone, s3cmd и других инструментов без изменений в коде.
|
||||
{locale === 'en'
|
||||
? 'Full S3 API, support for AWS CLI, Terraform, rclone, s3cmd and other tools without code changes.'
|
||||
: 'Полный S3 API, поддержка AWS CLI, Terraform, rclone, s3cmd и других инструментов без изменений в коде.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +261,7 @@ const S3PlansPage = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">{section.label} Tier</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Подберите план по объёму хранилища и включённому трафику
|
||||
{locale === 'en' ? 'Choose a plan by storage volume and included traffic' : 'Подберите план по объёму хранилища и включённому трафику'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -263,21 +282,21 @@ const S3PlansPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold text-gray-900">₽{plan.price.toLocaleString('ru-RU')}</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">в месяц</span>
|
||||
<span className="text-4xl font-bold text-gray-900">₽{plan.price.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">{locale === 'en' ? 'per month' : 'в месяц'}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm mb-6">
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Хранилище</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Storage' : 'Хранилище'}</span>
|
||||
<span className="font-semibold text-gray-900">{formatMetric(plan.quotaGb, 'GB')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Исходящий трафик</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Outbound traffic' : 'Исходящий трафик'}</span>
|
||||
<span className="font-semibold text-gray-900">{formatMetric(plan.bandwidthGb, 'GB')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Запросы</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Requests' : 'Запросы'}</span>
|
||||
<span className="font-semibold text-gray-900">{plan.requestLimit}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,11 +325,11 @@ const S3PlansPage = () => {
|
||||
{selectingPlan === plan.code ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Создание корзины...</span>
|
||||
<span>{locale === 'en' ? 'Creating cart...' : 'Создание корзины...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Выбрать план</span>
|
||||
<span>{locale === 'en' ? 'Select plan' : 'Выбрать план'}</span>
|
||||
<FaArrowRight />
|
||||
</>
|
||||
)}
|
||||
@@ -326,8 +345,8 @@ const S3PlansPage = () => {
|
||||
{customPlan && customPlanCalculated && (
|
||||
<div className="mt-20 pt-20 border-t border-gray-200">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Кастомный тариф</h2>
|
||||
<p className="text-gray-600">Укажите нужное количество GB и получите автоматический расчёт стоимости</p>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">{locale === 'en' ? 'Custom Plan' : 'Кастомный тариф'}</h2>
|
||||
<p className="text-gray-600">{locale === 'en' ? 'Specify the required amount of GB and get automatic cost calculation' : 'Укажите нужное количество GB и получите автоматический расчёт стоимости'}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-8 border border-blue-200">
|
||||
@@ -335,7 +354,7 @@ const S3PlansPage = () => {
|
||||
{/* Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-4">
|
||||
Сколько GB вам нужно?
|
||||
{locale === 'en' ? 'How many GB do you need?' : 'Сколько GB вам нужно?'}
|
||||
</label>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<input
|
||||
@@ -359,7 +378,7 @@ const S3PlansPage = () => {
|
||||
<span className="text-lg font-semibold text-gray-900 min-w-fit">GB</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
До {maxStorageGb.toLocaleString('ru-RU')} GB
|
||||
{locale === 'en' ? `Up to ${maxStorageGb.toLocaleString('en-US')} GB` : `До ${maxStorageGb.toLocaleString('ru-RU')} GB`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -373,24 +392,24 @@ const S3PlansPage = () => {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
<span className="text-gray-500 ml-2 text-sm">в месяц</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">{locale === 'en' ? 'per month' : 'в месяц'}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm mb-6">
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Хранилище</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Storage' : 'Хранилище'}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{customPlanCalculated.quotaGb.toLocaleString('ru-RU')} GB
|
||||
{customPlanCalculated.quotaGb.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Исходящий трафик</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Outbound traffic' : 'Исходящий трафик'}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{customPlanCalculated.bandwidthGb.toLocaleString('ru-RU')} GB
|
||||
{customPlanCalculated.bandwidthGb.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Запросы</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Requests' : 'Запросы'}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{customPlanCalculated.requestLimit}
|
||||
</span>
|
||||
@@ -410,11 +429,11 @@ const S3PlansPage = () => {
|
||||
{selectingPlan === customPlan.code ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Создание корзины...</span>
|
||||
<span>{locale === 'en' ? 'Creating cart...' : 'Создание корзины...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Выбрать кастомный план</span>
|
||||
<span>{locale === 'en' ? 'Select custom plan' : 'Выбрать кастомный план'}</span>
|
||||
<FaArrowRight />
|
||||
</>
|
||||
)}
|
||||
@@ -427,13 +446,13 @@ const S3PlansPage = () => {
|
||||
|
||||
<div className="mt-20 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Нужна гибридная архитектура или больше 5 PB хранения?
|
||||
{locale === 'en' ? 'Need hybrid architecture or more than 5 PB storage?' : 'Нужна гибридная архитектура или больше 5 PB хранения?'}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:sales@ospab.host"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Связаться с командой продаж
|
||||
{locale === 'en' ? 'Contact sales team' : 'Связаться с командой продаж'}
|
||||
<FaArrowRight />
|
||||
</a>
|
||||
</div>
|
||||
@@ -443,25 +462,31 @@ const S3PlansPage = () => {
|
||||
<section className="py-20 px-6 sm:px-8 bg-white">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
|
||||
Подходит для любых сценариев
|
||||
{locale === 'en' ? 'Suitable for any scenario' : 'Подходит для любых сценариев'}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-3">Бэкапы и DR</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">{locale === 'en' ? 'Backups & DR' : 'Бэкапы и DR'}</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.
|
||||
{locale === 'en'
|
||||
? 'Replication, Object Lock and object lifecycle allow storing backups and quick recovery.'
|
||||
: 'Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-3">Медиа-платформы</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">{locale === 'en' ? 'Media Platforms' : 'Медиа-платформы'}</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.
|
||||
{locale === 'en'
|
||||
? 'CDN integration, presigned URLs and high bandwidth for video, images and audio.'
|
||||
: 'CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-3">SaaS & Data Lake</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.
|
||||
{locale === 'en'
|
||||
? 'IAM, API versions and audit logs ensure security and compliance with GDPR, 152-FZ and SOC 2.'
|
||||
: 'IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,22 +495,24 @@ const S3PlansPage = () => {
|
||||
|
||||
<section className="py-20 px-6 sm:px-8 bg-gray-900 text-white">
|
||||
<div className="container mx-auto max-w-4xl text-center">
|
||||
<h2 className="text-4xl font-bold mb-6">Готовы развернуть S3 хранилище?</h2>
|
||||
<h2 className="text-4xl font-bold mb-6">{locale === 'en' ? 'Ready to deploy S3 storage?' : 'Готовы развернуть S3 хранилище?'}</h2>
|
||||
<p className="text-lg sm:text-xl mb-8 text-white/80">
|
||||
Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.
|
||||
{locale === 'en'
|
||||
? 'Create an account and get access to management console, API keys and detailed usage analytics.'
|
||||
: 'Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
to="/register"
|
||||
to={localePath('/register')}
|
||||
className="px-8 py-4 bg-white text-blue-600 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
to={localePath('/login')}
|
||||
className="px-8 py-4 bg-blue-500 text-white rounded-lg font-semibold hover:bg-blue-400 transition-colors"
|
||||
>
|
||||
Войти
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
171
ospabhost/frontend/src/utils/emailValidation.ts
Normal file
171
ospabhost/frontend/src/utils/emailValidation.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Email validation service
|
||||
* Uses multiple validation methods for reliable email checking
|
||||
*/
|
||||
|
||||
// Regex for basic email format validation
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
// Common disposable email domains (partial list)
|
||||
const DISPOSABLE_DOMAINS = new Set([
|
||||
'10minutemail.com',
|
||||
'tempmail.com',
|
||||
'throwaway.email',
|
||||
'guerrillamail.com',
|
||||
'mailinator.com',
|
||||
'temp-mail.org',
|
||||
'fakeinbox.com',
|
||||
'trashmail.com',
|
||||
'maildrop.cc',
|
||||
'yopmail.com',
|
||||
'sharklasers.com',
|
||||
'grr.la',
|
||||
'getairmail.com',
|
||||
'dispostable.com',
|
||||
]);
|
||||
|
||||
// Common typos in email domains
|
||||
const DOMAIN_TYPOS: Record<string, string> = {
|
||||
'gmial.com': 'gmail.com',
|
||||
'gmal.com': 'gmail.com',
|
||||
'gmil.com': 'gmail.com',
|
||||
'gmail.co': 'gmail.com',
|
||||
'gamil.com': 'gmail.com',
|
||||
'gmaill.com': 'gmail.com',
|
||||
'gnail.com': 'gmail.com',
|
||||
'yahooo.com': 'yahoo.com',
|
||||
'yaho.com': 'yahoo.com',
|
||||
'hotmal.com': 'hotmail.com',
|
||||
'hotmai.com': 'hotmail.com',
|
||||
'hotmial.com': 'hotmail.com',
|
||||
'outlok.com': 'outlook.com',
|
||||
'outloo.com': 'outlook.com',
|
||||
'yandx.ru': 'yandex.ru',
|
||||
'yanex.ru': 'yandex.ru',
|
||||
'mail.r': 'mail.ru',
|
||||
'mal.ru': 'mail.ru',
|
||||
};
|
||||
|
||||
export type EmailValidationResult = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
suggestion?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Basic email format validation
|
||||
*/
|
||||
export function validateEmailFormat(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email domain is disposable
|
||||
*/
|
||||
export function isDisposableEmail(email: string): boolean {
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
return domain ? DISPOSABLE_DOMAINS.has(domain) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for common typos and suggest corrections
|
||||
*/
|
||||
export function suggestEmailCorrection(email: string): string | null {
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const domain = parts[1].toLowerCase();
|
||||
const suggestion = DOMAIN_TYPOS[domain];
|
||||
|
||||
if (suggestion) {
|
||||
return `${parts[0]}@${suggestion}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email with all checks
|
||||
*/
|
||||
export function validateEmail(email: string, locale: 'ru' | 'en' = 'ru'): EmailValidationResult {
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
|
||||
// Check if empty
|
||||
if (!trimmedEmail) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: locale === 'en' ? 'Email is required' : 'Email обязателен',
|
||||
};
|
||||
}
|
||||
|
||||
// Check format
|
||||
if (!validateEmailFormat(trimmedEmail)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: locale === 'en' ? 'Invalid email format' : 'Неверный формат email',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for disposable emails
|
||||
if (isDisposableEmail(trimmedEmail)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: locale === 'en'
|
||||
? 'Disposable email addresses are not allowed'
|
||||
: 'Одноразовые email-адреса не допускаются',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for typos and suggest
|
||||
const suggestion = suggestEmailCorrection(trimmedEmail);
|
||||
if (suggestion) {
|
||||
return {
|
||||
isValid: true,
|
||||
suggestion: locale === 'en'
|
||||
? `Did you mean ${suggestion}?`
|
||||
: `Возможно, вы имели в виду ${suggestion}?`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Async email validation with API check (optional)
|
||||
* Uses abstract API for deep validation if API key is provided
|
||||
*/
|
||||
export async function validateEmailAsync(
|
||||
email: string,
|
||||
locale: 'ru' | 'en' = 'ru'
|
||||
): Promise<EmailValidationResult> {
|
||||
// First do basic validation
|
||||
const basicResult = validateEmail(email, locale);
|
||||
if (!basicResult.isValid) {
|
||||
return basicResult;
|
||||
}
|
||||
|
||||
// Optional: Use Abstract API for deeper validation
|
||||
// const apiKey = import.meta.env.VITE_ABSTRACT_EMAIL_API_KEY;
|
||||
// if (apiKey) {
|
||||
// try {
|
||||
// const response = await fetch(
|
||||
// `https://emailvalidation.abstractapi.com/v1/?api_key=${apiKey}&email=${encodeURIComponent(email)}`
|
||||
// );
|
||||
// const data = await response.json();
|
||||
//
|
||||
// if (!data.deliverability || data.deliverability === 'UNDELIVERABLE') {
|
||||
// return {
|
||||
// isValid: false,
|
||||
// error: locale === 'en'
|
||||
// ? 'This email address appears to be invalid'
|
||||
// : 'Этот email-адрес недействителен',
|
||||
// };
|
||||
// }
|
||||
// } catch (err) {
|
||||
// // If API fails, continue with basic validation result
|
||||
// console.warn('Email API validation failed:', err);
|
||||
// }
|
||||
// }
|
||||
|
||||
return basicResult;
|
||||
}
|
||||
Reference in New Issue
Block a user