english version update

This commit is contained in:
Georgiy Syralev
2025-12-31 19:59:43 +03:00
parent b799f278a4
commit a2809a705f
57 changed files with 4263 additions and 1333 deletions

154
ospabhost/NGINX_DEPLOY.md Normal file
View File

@@ -0,0 +1,154 @@
# Nginx Deployment Guide for ospab.host
## Prerequisites
- Ubuntu 20.04+ или Debian 11+
- Nginx 1.18+
- Node.js 18+
- PM2 (для управления процессами)
- Certbot (для SSL)
## Installation
### 1. Install Nginx
```bash
sudo apt update
sudo apt install nginx -y
```
### 2. Install Certbot
```bash
sudo apt install certbot python3-certbot-nginx -y
```
### 3. Setup SSL Certificate
```bash
# Stop nginx temporarily
sudo systemctl stop nginx
# Get certificate
sudo certbot certonly --standalone -d ospab.host -d www.ospab.host
# Restart nginx
sudo systemctl start nginx
```
### 4. Deploy Nginx Configuration
```bash
# Copy config
sudo cp nginx.conf /etc/nginx/sites-available/ospab.host
# Create symlink
sudo ln -s /etc/nginx/sites-available/ospab.host /etc/nginx/sites-enabled/
# Remove default config
sudo rm /etc/nginx/sites-enabled/default
# Test configuration
sudo nginx -t
# Reload nginx
sudo systemctl reload nginx
```
### 5. Deploy Application
```bash
# Create deployment directory
sudo mkdir -p /var/www/ospab.host
# Clone repository
cd /var/www/ospab.host
git clone https://github.com/YOUR_REPO/ospabhost8.1.git .
# Build frontend
cd frontend
npm install
npm run build
# Build backend
cd ../backend
npm install
npm run build
# Start backend with PM2
pm2 start dist/index.js --name "ospab-backend"
pm2 save
pm2 startup
```
## Directory Structure
```
/var/www/ospab.host/
├── frontend/
│ └── dist/ # React SPA build output
├── backend/
│ ├── dist/ # Compiled TypeScript
│ └── uploads/ # Uploaded files
└── nginx.conf # Nginx configuration
```
## Useful Commands
```bash
# Check nginx status
sudo systemctl status nginx
# Reload nginx config
sudo nginx -t && sudo systemctl reload nginx
# View logs
sudo tail -f /var/log/nginx/ospab.host.access.log
sudo tail -f /var/log/nginx/ospab.host.error.log
# PM2 commands
pm2 status
pm2 logs ospab-backend
pm2 restart ospab-backend
# Renew SSL certificate
sudo certbot renew --dry-run
```
## Rate Limiting
- API endpoints: 10 requests/second (burst 20)
- Login/Register: 5 requests/minute (burst 5)
## Security Features
- HSTS enabled
- XSS Protection
- Frame Options (SAMEORIGIN)
- Content-Type sniffing prevention
- Blocked access to .git, .env, node_modules
- Blocked sensitive file extensions (.sql, .bak, .log)
## SSL Auto-Renewal
Add to crontab:
```bash
sudo crontab -e
# Add line:
0 12 * * * /usr/bin/certbot renew --quiet
```
## Troubleshooting
### 502 Bad Gateway
- Check if backend is running: `pm2 status`
- Check backend logs: `pm2 logs ospab-backend`
### 504 Gateway Timeout
- Increase `proxy_read_timeout` in nginx config
- Check backend performance
### SSL Issues
- Check certificate: `sudo certbot certificates`
- Renew if needed: `sudo certbot renew`

View File

@@ -13,33 +13,35 @@ datasource db {
// VPS/Server models removed - moving to S3 storage
model User {
id Int @id @default(autoincrement())
username String
email String @unique
password String
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
username String
email String @unique
password String
createdAt DateTime @default(now())
// plans Plan[] @relation("UserPlans")
operator Int @default(0)
isAdmin Boolean @default(false)
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
notifications Notification[]
operator Int @default(0)
isAdmin Boolean @default(false)
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
notifications Notification[]
pushSubscriptions PushSubscription[]
transactions Transaction[] // История всех транзакций
posts Post[] @relation("PostAuthor") // Статьи блога
comments Comment[] @relation("UserComments") // Комментарии
buckets StorageBucket[] // S3 хранилища пользователя
checkoutSessions StorageCheckoutSession[]
transactions Transaction[] // История всех транзакций
posts Post[] @relation("PostAuthor") // Статьи блога
comments Comment[] @relation("UserComments") // Комментарии
buckets StorageBucket[] // S3 хранилища пользователя
checkoutSessions StorageCheckoutSession[]
// Список промокодов, использованных пользователем
usedPromoCodes PromoCode[]
// Новые relations для расширенных настроек
sessions Session[]
loginHistory LoginHistory[]
apiKeys APIKey[]
notificationSettings NotificationSettings?
profile UserProfile?
qrLoginRequests QrLoginRequest[]
sessions Session[]
loginHistory LoginHistory[]
apiKeys APIKey[]
notificationSettings NotificationSettings?
profile UserProfile?
qrLoginRequests QrLoginRequest[]
@@map("user")
}
@@ -53,48 +55,47 @@ model Check {
createdAt DateTime @default(now())
user User @relation("UserChecks", fields: [userId], references: [id])
@@map("check")
@@map("check")
}
model Service {
id Int @id @default(autoincrement())
name String @unique
price Float
id Int @id @default(autoincrement())
name String @unique
price Float
// planId Int?
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
@@map("service")
@@map("service")
}
model Ticket {
id Int @id @default(autoincrement())
title String
message String @db.Text
userId Int
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
priority String @default("normal") // low, normal, high, urgent
category String @default("general") // general, technical, billing, other
assignedTo Int? // ID оператора, которому назначен тикет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
closedAt DateTime?
responses Response[] @relation("TicketResponses")
id Int @id @default(autoincrement())
title String
message String @db.Text
userId Int
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
priority String @default("normal") // low, normal, high, urgent
category String @default("general") // general, technical, billing, other
assignedTo Int? // ID оператора, которому назначен тикет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
closedAt DateTime?
responses Response[] @relation("TicketResponses")
attachments TicketAttachment[]
user User? @relation("UserTickets", fields: [userId], references: [id])
user User? @relation("UserTickets", fields: [userId], references: [id])
@@map("ticket")
}
model Response {
id Int @id @default(autoincrement())
ticketId Int
operatorId Int
message String @db.Text
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
createdAt DateTime @default(now())
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
id Int @id @default(autoincrement())
ticketId Int
operatorId Int
message String @db.Text
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
createdAt DateTime @default(now())
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
attachments ResponseAttachment[]
@@map("response")
@@ -102,14 +103,14 @@ model Response {
// Прикреплённые файлы к тикетам
model TicketAttachment {
id Int @id @default(autoincrement())
ticketId Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
ticketId Int
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int // Размер в байтах
mimeType String
filename String
fileUrl String
fileSize Int // Размер в байтах
mimeType String
createdAt DateTime @default(now())
@@ -122,30 +123,30 @@ model ResponseAttachment {
responseId Int
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
filename String
fileUrl String
fileSize Int
mimeType String
filename String
fileUrl String
fileSize Int
mimeType String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@map("response_attachment")
}
// QR-код авторизация (как в Telegram Web)
model QrLoginRequest {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(128) // Уникальный код QR
userId Int? // После подтверждения - ID пользователя
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
code String @unique @db.VarChar(128) // Уникальный код QR
userId Int? // После подтверждения - ID пользователя
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
status String @default("pending") // pending, confirmed, expired, rejected
ipAddress String?
userAgent String? @db.Text
status String @default("pending") // pending, confirmed, expired, rejected
ipAddress String?
userAgent String? @db.Text
createdAt DateTime @default(now())
expiresAt DateTime // Через 60 секунд
confirmedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime // Через 60 секунд
confirmedAt DateTime?
@@index([code])
@@index([status, expiresAt])
@@ -154,34 +155,34 @@ model QrLoginRequest {
// История всех транзакций (пополнения, списания, возвраты)
model Transaction {
id Int @id @default(autoincrement())
userId Int
amount Float
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
description String
id Int @id @default(autoincrement())
userId Int
amount Float
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
description String
balanceBefore Float
balanceAfter Float
adminId Int? // ID админа, если операция выполнена админом
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
adminId Int? // ID админа, если операция выполнена админом
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@map("transaction")
}
// Блог
model Post {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
title String
content String @db.Text // Rich text content (HTML)
excerpt String? @db.Text // Краткое описание для ленты
coverImage String? // URL обложки
url String @unique // Пользовательский URL (blog_name)
status String @default("draft") // draft, published, archived
content String @db.Text // Rich text content (HTML)
excerpt String? @db.Text // Краткое описание для ленты
coverImage String? // URL обложки
url String @unique // Пользовательский URL (blog_name)
status String @default("draft") // draft, published, archived
authorId Int
author User @relation("PostAuthor", fields: [authorId], references: [id])
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation("PostAuthor", fields: [authorId], references: [id])
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
comments Comment[]
@@ -190,37 +191,37 @@ model Post {
// Комментарии к статьям блога
model Comment {
id Int @id @default(autoincrement())
postId Int
userId Int? // null если комментарий от гостя
authorName String? // Имя автора (для гостей)
content String @db.Text
status String @default("pending") // pending, approved, rejected
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("UserComments", fields: [userId], references: [id])
id Int @id @default(autoincrement())
postId Int
userId Int? // null если комментарий от гостя
authorName String? // Имя автора (для гостей)
content String @db.Text
status String @default("pending") // pending, approved, rejected
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
user User? @relation("UserComments", fields: [userId], references: [id])
@@map("comment")
}
// Модель для уведомлений
model Notification {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
title String
message String @db.Text
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
title String
message String @db.Text
// Связанные сущности (опционально)
ticketId Int?
checkId Int?
ticketId Int?
checkId Int?
// Метаданные
actionUrl String? // URL для перехода при клике
icon String? // Иконка (emoji или path)
color String? // Цвет (green, blue, orange, red, purple)
actionUrl String? // URL для перехода при клике
icon String? // Иконка (emoji или path)
color String? // Цвет (green, blue, orange, red, purple)
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@ -232,13 +233,13 @@ model Notification {
// Модель для Push-подписок (Web Push API)
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @db.VarChar(512)
p256dh String @db.Text // Публичный ключ для шифрования
auth String @db.Text // Токен аутентификации
endpoint String @db.VarChar(512)
p256dh String @db.Text // Публичный ключ для шифрования
auth String @db.Text // Токен аутентификации
userAgent String? @db.Text // Браузер/устройство
@@ -252,16 +253,16 @@ model PushSubscription {
// Активные сеансы пользователя
model Session {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique @db.VarChar(500) // JWT refresh token
token String @unique @db.VarChar(500) // JWT refresh token
ipAddress String?
userAgent String? @db.Text
device String? // Desktop, Mobile, Tablet
browser String? // Chrome, Firefox, Safari, etc.
location String? // Город/страна по IP
userAgent String? @db.Text
device String? // Desktop, Mobile, Tablet
browser String? // Chrome, Firefox, Safari, etc.
location String? // Город/страна по IP
lastActivity DateTime @default(now())
createdAt DateTime @default(now())
@@ -273,12 +274,12 @@ model Session {
// История входов
model LoginHistory {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ipAddress String
userAgent String? @db.Text
userAgent String? @db.Text
device String?
browser String?
location String?
@@ -293,19 +294,19 @@ model LoginHistory {
// API ключи для разработчиков
model APIKey {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Название (например, "Production API")
key String @unique @db.VarChar(64) // Сам API ключ
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
name String // Название (например, "Production API")
key String @unique @db.VarChar(64) // Сам API ключ
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.]
lastUsed DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
lastUsed DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
@@index([userId])
@@index([key])
@@ -319,15 +320,15 @@ model NotificationSettings {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Email уведомления
emailBalanceLow Boolean @default(true)
emailPaymentCharged Boolean @default(true)
emailTicketReply Boolean @default(true)
emailNewsletter Boolean @default(false)
emailBalanceLow Boolean @default(true)
emailPaymentCharged Boolean @default(true)
emailTicketReply Boolean @default(true)
emailNewsletter Boolean @default(false)
// Push уведомления
pushBalanceLow Boolean @default(true)
pushPaymentCharged Boolean @default(true)
pushTicketReply Boolean @default(true)
pushBalanceLow Boolean @default(true)
pushPaymentCharged Boolean @default(true)
pushTicketReply Boolean @default(true)
updatedAt DateTime @updatedAt
@@ -336,14 +337,14 @@ model NotificationSettings {
// Настройки профиля
model UserProfile {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
avatarUrl String? // Путь к аватару
avatarUrl String? // Путь к аватару
phoneNumber String?
timezone String? @default("Europe/Moscow")
language String? @default("ru")
timezone String? @default("Europe/Moscow")
language String? @default("ru")
// Настройки приватности
profilePublic Boolean @default(false)
@@ -353,59 +354,59 @@ model UserProfile {
twoFactorEnabled Boolean @default(false)
twoFactorSecret String? @db.Text
updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt
@@map("user_profile")
}
// S3 Bucket модель
model StorageBucket {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Уникальное имя бакета в рамках пользователя
plan String // Код тарифа из StoragePlan
quotaGb Int // Лимит включённого объёма в GB
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
objectCount Int @default(0)
storageClass String @default("standard") // standard, infrequent, archive
region String @default("ru-central-1")
public Boolean @default(false)
versioning Boolean @default(false)
status String @default("active") // active, grace, suspended
monthlyPrice Float
nextBillingDate DateTime?
lastBilledAt DateTime?
autoRenew Boolean @default(true)
usageSyncedAt DateTime?
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
consoleCredential StorageConsoleCredential?
name String // Уникальное имя бакета в рамках пользователя
plan String // Код тарифа из StoragePlan
quotaGb Int // Лимит включённого объёма в GB
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
objectCount Int @default(0)
storageClass String @default("standard") // standard, infrequent, archive
region String @default("ru-central-1")
public Boolean @default(false)
versioning Boolean @default(false)
status String @default("active") // active, grace, suspended
monthlyPrice Float
nextBillingDate DateTime?
lastBilledAt DateTime?
autoRenew Boolean @default(true)
usageSyncedAt DateTime?
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
consoleCredential StorageConsoleCredential?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accessKeys StorageAccessKey[]
accessKeys StorageAccessKey[]
@@unique([userId, name]) // Имя уникально в рамках пользователя
@@index([userId])
@@index([region])
@@index([storageClass])
@@unique([userId, name]) // Имя уникально в рамках пользователя
@@map("storage_bucket")
}
model StorageAccessKey {
id Int @id @default(autoincrement())
bucketId Int
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
bucketId Int
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
accessKey String @unique
secretKey String // хранится в зашифрованном виде
accessKey String @unique
secretKey String // хранится в зашифрованном виде
label String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([bucketId])
@@ -413,36 +414,36 @@ model StorageAccessKey {
}
model StorageConsoleCredential {
id Int @id @default(autoincrement())
bucketId Int @unique
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
bucketId Int @unique
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
login String
passwordHash String
login String
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю)
@@map("storage_console_credential")
}
model StoragePlan {
id Int @id @default(autoincrement())
code String @unique
name String
price Float
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
requestsPerGb Int? // Количество операций на 1 GB хранения
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
requestLimit String // Текстовое описание лимита операций
order Int @default(0)
isActive Boolean @default(true)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
id Int @id @default(autoincrement())
code String @unique
name String
price Float
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
requestsPerGb Int? // Количество операций на 1 GB хранения
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
requestLimit String // Текстовое описание лимита операций
order Int @default(0)
isActive Boolean @default(true)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
buckets StorageBucket[]
checkoutSessions StorageCheckoutSession[]
@@ -451,9 +452,9 @@ model StoragePlan {
}
model StorageCheckoutSession {
id String @id @default(uuid())
id String @id @default(uuid())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
planId Int
plan StoragePlan @relation(fields: [planId], references: [id])
planCode String
@@ -461,12 +462,12 @@ model StorageCheckoutSession {
planDescription String?
price Float
promoCodeId Int?
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
promoDiscount Float? @default(0)
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
promoDiscount Float? @default(0)
quotaGb Int
bandwidthGb Int
requestLimit String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
expiresAt DateTime
consumedAt DateTime?
@@ -486,40 +487,43 @@ model StorageRegion {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buckets StorageBucket[] @relation("BucketRegion")
buckets StorageBucket[] @relation("BucketRegion")
@@map("storage_region")
}
model StorageClass {
id Int @id @default(autoincrement())
code String @unique
name String
description String?
redundancy String?
performance String?
retrievalFee String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
code String @unique
name String
description String?
redundancy String?
performance String?
retrievalFee String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buckets StorageBucket[] @relation("BucketClass")
buckets StorageBucket[] @relation("BucketClass")
@@map("storage_class")
}
model PromoCode {
id Int @id @default(autoincrement())
code String @unique
amount Float // discount amount in RUB
used Boolean @default(false)
id Int @id @default(autoincrement())
code String @unique
amount Float // discount amount in RUB
used Boolean @default(false)
usedBy Int?
usedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [usedBy], references: [id])
user User? @relation(fields: [usedBy], references: [id], onDelete: SetNull)
// Обратная связь для корзин, в которые применяли этот промокод
checkoutSessions StorageCheckoutSession[]
@@map("promo_code")
}

View File

@@ -112,14 +112,71 @@ app.get('/', async (req, res) => {
app.get('/sitemap.xml', (req, res) => {
const baseUrl = 'https://ospab.host';
const staticPages = [
{ loc: '/', priority: '1.0', changefreq: 'weekly', description: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов' },
{ loc: '/about', priority: '0.9', changefreq: 'monthly', description: 'О компании - Современная платформа хранения данных' },
{ loc: '/login', priority: '0.7', changefreq: 'monthly', description: 'Вход в панель управления с QR-аутентификацией' },
{ loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' },
{ loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' },
{ loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' },
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' },
const pages = [
// Главная страница
{
loc: '/',
priority: '1.0',
changefreq: 'weekly',
ru: { title: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов', description: 'Надёжное облачное S3-совместимое хранилище в Великом Новгороде' },
en: { title: 'Ospab Host - Cloud S3 Storage and Website Hosting', description: 'Reliable S3-compatible cloud storage in Veliky Novgorod' }
},
// О компании
{
loc: '/about',
priority: '0.9',
changefreq: 'monthly',
ru: { title: 'О компании - Современная платформа хранения данных', description: 'Узнайте о ospab.host - платформе облачного хранилища в Великом Новгороде' },
en: { title: 'About Us - Modern Data Storage Platform', description: 'Learn about ospab.host - cloud storage platform in Veliky Novgorod' }
},
// Вход
{
loc: '/login',
priority: '0.7',
changefreq: 'monthly',
ru: { title: 'Вход в панель управления с QR-аутентификацией', description: 'Войдите в ваш личный кабинет ospab.host' },
en: { title: 'Login to Control Panel with QR Authentication', description: 'Sign in to your ospab.host account' }
},
// Регистрация
{
loc: '/register',
priority: '0.8',
changefreq: 'monthly',
ru: { title: 'Регистрация аккаунта - Начните за 2 минуты', description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем' },
en: { title: 'Account Registration - Start in 2 Minutes', description: 'Register with ospab.host and start using cloud storage' }
},
// Блог
{
loc: '/blog',
priority: '0.85',
changefreq: 'daily',
ru: { title: 'Блог о S3 хранилище и хостинге', description: 'Статьи о хостинге, S3 хранилище и облачных технологиях' },
en: { title: 'Blog about S3 Storage and Hosting', description: 'Articles about hosting, S3 storage and cloud technologies' }
},
// Тарифы
{
loc: '/tariffs',
priority: '0.9',
changefreq: 'weekly',
ru: { title: 'Тарифы на облачное S3 хранилище', description: 'Выберите подходящий тариф для вашего проекта' },
en: { title: 'Cloud S3 Storage Plans', description: 'Choose the right plan for your project' }
},
// Условия использования
{
loc: '/terms',
priority: '0.5',
changefreq: 'yearly',
ru: { title: 'Условия использования сервиса', description: 'Правила и условия использования ospab.host' },
en: { title: 'Terms of Service', description: 'Rules and conditions for using ospab.host' }
},
// Политика конфиденциальности
{
loc: '/privacy',
priority: '0.5',
changefreq: 'yearly',
ru: { title: 'Политика конфиденциальности и защита данных', description: 'Как мы защищаем ваши данные и обеспечиваем конфиденциальность' },
en: { title: 'Privacy Policy and Data Protection', description: 'How we protect your data and ensure privacy' }
}
];
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
@@ -127,12 +184,25 @@ app.get('/sitemap.xml', (req, res) => {
const lastmod = new Date().toISOString().split('T')[0];
for (const page of staticPages) {
for (const page of pages) {
// Русская версия (без префикса)
xml += ' <url>\n';
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
xml += ` <lastmod>${lastmod}</lastmod>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
xml += ' <xhtml:link rel="alternate" hreflang="ru" href="' + baseUrl + page.loc + '"/>\n';
xml += ' <xhtml:link rel="alternate" hreflang="en" href="' + baseUrl + '/en' + page.loc + '"/>\n';
xml += ' </url>\n';
// Английская версия (с префиксом /en)
xml += ' <url>\n';
xml += ` <loc>${baseUrl}/en${page.loc}</loc>\n`;
xml += ` <lastmod>${lastmod}</lastmod>\n`;
xml += ` <priority>${page.priority}</priority>\n`;
xml += ` <changefreq>${page.changefreq}</changefreq>\n`;
xml += ' <xhtml:link rel="alternate" hreflang="ru" href="' + baseUrl + page.loc + '"/>\n';
xml += ' <xhtml:link rel="alternate" hreflang="en" href="' + baseUrl + '/en' + page.loc + '"/>\n';
xml += ' </url>\n';
}
@@ -145,20 +215,29 @@ app.get('/sitemap.xml', (req, res) => {
// ==================== ROBOTS.TXT ====================
app.get('/robots.txt', (req, res) => {
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
# Хранение данных, техподдержка 24/7
# Cloud S3 Storage and Website Hosting
User-agent: *
Allow: /
Allow: /about
Allow: /en/about
Allow: /login
Allow: /en/login
Allow: /register
Allow: /en/register
Allow: /blog
Allow: /en/blog
Allow: /blog/*
Allow: /en/blog/*
Allow: /tariffs
Allow: /en/tariffs
Allow: /terms
Allow: /en/terms
Allow: /privacy
Allow: /en/privacy
Allow: /uploads/blog
# Запрет индексации приватных разделов
# Disallow private sections / Запрет индексации приватных разделов
Disallow: /dashboard
Disallow: /dashboard/*
Disallow: /api/
@@ -171,7 +250,7 @@ Disallow: /uploads/checks
Sitemap: https://ospab.host/sitemap.xml
# Поисковые роботы
# Search engine robots / Поисковые роботы
User-agent: Googlebot
Allow: /
Crawl-delay: 0

View File

@@ -10,12 +10,18 @@ import {
} from './account.service';
import { prisma } from '../../prisma/client';
// Хелпер для извлечения сообщения из ошибки
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return getErrorMessage(error);
return String(error);
}
/**
* Получить информацию о текущем пользователе
*/
export const getAccountInfo = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -33,7 +39,7 @@ export const getAccountInfo = async (req: Request, res: Response) => {
*/
export const requestPasswordChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -71,7 +77,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка запроса смены пароля:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -80,7 +86,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
*/
export const confirmPasswordChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -99,7 +105,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка подтверждения смены пароля:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};
@@ -108,7 +114,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
*/
export const requestUsernameChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -139,7 +145,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка запроса смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка сервера' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -148,7 +154,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
*/
export const confirmUsernameChangeHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -167,7 +173,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка подтверждения смены имени:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};
@@ -176,7 +182,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
*/
export const requestAccountDeletionHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -189,7 +195,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка запроса удаления аккаунта:', error);
res.status(500).json({ error: error.message || 'Ошибка сервера' });
res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' });
}
};
@@ -198,7 +204,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
*/
export const confirmAccountDeletionHandler = async (req: Request, res: Response) => {
try {
const userId = (req.user as any)?.id;
const userId = (req.user as { id?: number })?.id;
if (!userId) {
return res.status(401).json({ error: 'Не авторизован' });
}
@@ -217,7 +223,8 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
});
} catch (error: unknown) {
console.error('Ошибка подтверждения удаления аккаунта:', error);
res.status(400).json({ error: error.message || 'Ошибка подтверждения' });
res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' });
}
};

View File

@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { createNotification } from '../notification/notification.controller';
import { sendNotificationEmail } from '../notification/email.service';
function toNumeric(value: unknown): number {
if (typeof value === 'bigint') {
@@ -514,23 +515,50 @@ export class AdminController {
return res.status(404).json({ error: 'Пользователь не найден' });
}
if (!user.email) {
return res.status(400).json({ error: 'У пользователя не указан email' });
}
const now = new Date().toISOString();
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
console.log(logMsg);
// Здесь должна быть реальная отправка email (имитация)
await new Promise(resolve => setTimeout(resolve, 800));
// Отправляем реальное email уведомление
const emailResult = await sendNotificationEmail({
to: user.email,
username: user.username,
title: 'Тестовое уведомление',
message: 'Это тестовое email-уведомление от ospab.host. Если вы получили это письмо, email-уведомления настроены корректно.',
actionUrl: '/dashboard/notifications',
type: 'test_email'
});
if (emailResult.status === 'error') {
return res.status(500).json({
success: false,
error: `Ошибка отправки email: ${emailResult.message}`,
details: { userId: user.id, email: user.email, time: now }
});
}
if (emailResult.status === 'skipped') {
return res.status(400).json({
success: false,
error: 'SMTP не настроен. Укажите SMTP_USER и SMTP_PASS в переменных окружения.',
details: { userId: user.id, email: user.email, time: now }
});
}
return res.json({
success: true,
message: 'Email-уведомление успешно отправлено (тест)',
message: 'Email-уведомление успешно отправлено',
details: {
userId: user.id,
username: user.username,
email: user.email,
type: 'email',
time: now,
status: 'sent (mock)'
messageId: 'messageId' in emailResult ? emailResult.messageId : undefined
}
});
} catch (error) {

View File

@@ -4,7 +4,7 @@ import crypto from 'crypto';
import { createSession } from '../session/session.controller';
import { logger } from '../../utils/logger';
const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд
const QR_EXPIRATION_SECONDS = 180; // QR-код живёт 180 секунд (3 минуты)
// Генерировать уникальный код для QR
function generateQRCode(): string {
@@ -14,7 +14,7 @@ function generateQRCode(): string {
// Создать новый QR-запрос для логина
export async function createQRLoginRequest(req: Request, res: Response) {
try {
const code = generateQRCode();
const code = generateQRCode();
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
const userAgent = req.headers['user-agent'] || '';
@@ -31,6 +31,16 @@ export async function createQRLoginRequest(req: Request, res: Response) {
}
});
// Ensure QR creation is visible in production logs: write directly to stdout
console.log('[QR Create] Создан QR-запрос', JSON.stringify({
code: qrRequest.code,
ipAddress: qrRequest.ipAddress,
userAgent: qrRequest.userAgent?.toString?.().slice(0, 200),
host: req.headers.host,
origin: req.headers.origin,
referer: req.headers.referer
}));
res.json({
code: qrRequest.code,
expiresAt: qrRequest.expiresAt,
@@ -47,21 +57,38 @@ export async function checkQRStatus(req: Request, res: Response) {
try {
const { code } = req.params;
// Log incoming status checks for tracing
logger.debug('[QR Status] Проверка статуса QR', {
code,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
ua: (req.headers['user-agent'] || '').toString().slice(0, 200)
});
const qrRequest = await prisma.qrLoginRequest.findUnique({
where: { code }
});
if (!qrRequest) {
// Log as error so it appears in production logs — include host/origin/referer and remote IP for tracing
logger.error('[QR Status] QR-код не найден', {
code,
host: req.headers.host,
origin: req.headers.origin,
referer: req.headers.referer,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress
});
return res.status(404).json({ error: 'QR-код не найден' });
}
// Проверяем истёк ли QR-код
if (new Date() > qrRequest.expiresAt) {
const now = new Date();
const expiresIn = Math.max(0, Math.ceil((qrRequest.expiresAt.getTime() - now.getTime()) / 1000));
if (expiresIn <= 0) {
await prisma.qrLoginRequest.update({
where: { code },
data: { status: 'expired' }
});
return res.json({ status: 'expired' });
return res.json({ status: 'expired', expiresAt: qrRequest.expiresAt, expiresIn: 0 });
}
// Если подтверждён, создаём сессию и возвращаем токен
@@ -85,8 +112,12 @@ export async function checkQRStatus(req: Request, res: Response) {
// Создаём сессию для нового устройства
const { token } = await createSession(user.id, req);
// Удаляем использованный QR-запрос
await prisma.qrLoginRequest.delete({ where: { code } });
// Попытка безопасно удалить использованный QR-запрос (deleteMany не бросает если записи не найдено)
try {
await prisma.qrLoginRequest.deleteMany({ where: { code } });
} catch (err) {
logger.warn('[QR Status] Не удалось удалить QR-запрос (возможно уже удалён)', { code, error: err });
}
return res.json({
status: 'confirmed',
@@ -102,7 +133,7 @@ export async function checkQRStatus(req: Request, res: Response) {
});
}
res.json({ status: qrRequest.status });
return res.json({ status: qrRequest.status, expiresAt: qrRequest.expiresAt, expiresIn, ipAddress: qrRequest.ipAddress ?? undefined, userAgent: qrRequest.userAgent ? (qrRequest.userAgent as string).slice(0, 200) : undefined });
} catch (error) {
logger.error('Ошибка проверки статуса QR:', error);
res.status(500).json({ error: 'Ошибка проверки статуса' });
@@ -266,3 +297,61 @@ export async function cleanupExpiredQRRequests() {
logger.error('[QR Cleanup] Ошибка:', error);
}
}
// DEV-only: получить последние N QR-запросов (для отладки)
export async function listRecentQRRequests(req: Request, res: Response) {
try {
// In production allow only requests from localhost (for safe debugging)
if (process.env.NODE_ENV === 'production') {
const remote = req.socket.remoteAddress || '';
const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1');
if (!isLocal) {
return res.status(404).json({ error: 'Not found' });
}
}
const limit = Math.min(100, Number(req.query.limit) || 50);
const rows = await prisma.qrLoginRequest.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
code: true,
status: true,
ipAddress: true,
userAgent: true,
createdAt: true,
expiresAt: true,
userId: true
}
});
res.json({ count: rows.length, rows });
} catch (error) {
logger.error('[QR Debug] Ошибка получения списка QR-запросов:', error);
res.status(500).json({ error: 'Ошибка получения списка' });
}
}
// DEV-only: получить QR-запрос по коду
export async function getQRRequestByCode(req: Request, res: Response) {
try {
// In production allow only requests from localhost (for safe debugging)
if (process.env.NODE_ENV === 'production') {
const remote = req.socket.remoteAddress || '';
const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1');
if (!isLocal) {
return res.status(404).json({ error: 'Not found' });
}
}
const { code } = req.params;
const row = await prisma.qrLoginRequest.findUnique({ where: { code } });
if (!row) {
return res.status(404).json({ error: 'QR-код не найден' });
}
res.json(row);
} catch (error) {
logger.error('[QR Debug] Ошибка получения QR по коду:', error);
res.status(500).json({ error: 'Ошибка получения' });
}
}

View File

@@ -4,7 +4,9 @@ import {
checkQRStatus,
confirmQRLogin,
rejectQRLogin,
markQRAsScanning
markQRAsScanning,
listRecentQRRequests,
getQRRequestByCode
} from './qr-auth.controller';
import { authMiddleware } from '../auth/auth.middleware';
@@ -16,6 +18,10 @@ router.post('/generate', createQRLoginRequest);
// Проверить статус QR-кода (polling, публичный endpoint)
router.get('/status/:code', checkQRStatus);
// DEV-only debug endpoints (возвращают информацию о последних QR-запросах)
router.get('/debug/list', listRecentQRRequests);
router.get('/debug/get/:code', getQRRequestByCode);
// Отметить что пользователь открыл страницу подтверждения (требует авторизации)
router.post('/scanning', authMiddleware, markQRAsScanning);

View File

@@ -160,7 +160,8 @@ router.post('/cart/:id/apply-promo', authMiddleware, async (req, res) => {
return res.json({ success: true, cart: result });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Не удалось применить промокод';
return res.status(400).json({ error: message });
const status = typeof message === 'string' && message.includes('PromoCode модель недоступна') ? 500 : 400;
return res.status(status).json({ error: message });
}
});

View File

@@ -661,9 +661,13 @@ function buildPlanFromSession(session: CheckoutSessionRecord, plan?: StoragePlan
isActive: true,
};
// Для custom тарифа всегда берём значения из сессии
return {
...base,
price: toPlainNumber(session.price),
quotaGb: session.quotaGb,
bandwidthGb: session.bandwidthGb,
requestLimit: session.requestLimit,
};
}
@@ -672,6 +676,8 @@ type CheckoutSessionPayload = {
plan: ReturnType<typeof serializePlan>;
price: number;
expiresAt: string;
originalPrice?: number | null;
promoDiscount?: number | null;
};
type CheckoutSessionResult = {
@@ -694,11 +700,14 @@ function ensureSessionActive(session: CheckoutSessionRecord, userId: number): Ch
}
function toCheckoutPayload(session: CheckoutSessionRecord, plan?: StoragePlanRecord | null): CheckoutSessionPayload {
const original = plan ? Number(plan.price) : toPlainNumber(session.price);
return {
cartId: session.id,
plan: buildPlanFromSession(session, plan),
price: toPlainNumber(session.price),
expiresAt: session.expiresAt.toISOString(),
originalPrice: original,
promoDiscount: session.promoDiscount ?? null,
};
}

View File

@@ -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>

View File

@@ -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`}

View File

@@ -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>
)}

View File

@@ -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
}, []);
@@ -34,12 +40,29 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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">
&copy; {currentYear} ospab.host. Все права защищены.
&copy; {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>

View File

@@ -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>
</>
)}

View File

@@ -0,0 +1,3 @@
export { useTranslation, getTranslation } from './useTranslation';
export type { TranslationKey, TranslationKeys } from './useTranslation';
export { ru, en } from './translations';

View 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.',
},
};

View File

@@ -0,0 +1,2 @@
export { ru, type TranslationKeys } from './ru';
export { en } from './en';

View 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;

View 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 };

View File

@@ -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 {

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>&copy; 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',

View File

@@ -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>
) : (

View File

@@ -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>

View File

@@ -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__ && (

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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}
/>
);
};

View 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;

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View 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;
}

228
ospabhost/nginx.conf Normal file
View File

@@ -0,0 +1,228 @@
# ospab.host Nginx Configuration
# Production configuration for React SPA + Express Backend
# Upstream для бэкенда
upstream backend_api {
server 127.0.0.1:3001;
keepalive 32;
}
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name ospab.host www.ospab.host;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# Main HTTPS server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name ospab.host www.ospab.host;
# SSL Configuration
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# Root directory for frontend build
root /var/www/ospab.host/frontend/dist;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml application/rss+xml image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# API proxy to backend
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
# Error handling - redirect to custom error page
proxy_intercept_errors on;
error_page 502 503 504 = @error_page;
}
# Auth endpoints - stricter rate limiting
location /api/auth/login {
limit_req zone=login_limit burst=5 nodelay;
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_intercept_errors on;
error_page 502 503 504 = @error_page;
}
location /api/auth/register {
limit_req zone=login_limit burst=3 nodelay;
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_intercept_errors on;
error_page 502 503 504 = @error_page;
}
# WebSocket support for real-time features
location /ws {
proxy_pass http://backend_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
# Uploaded files (checks images)
location /uploads/ {
alias /var/www/ospab.host/backend/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Static assets with long cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Manifest and service worker
location ~* \.(webmanifest|manifest\.json)$ {
expires 1d;
add_header Cache-Control "public";
types {
application/manifest+json webmanifest;
}
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
# Robots.txt
location = /robots.txt {
access_log off;
try_files $uri =404;
}
# Sitemap
location = /sitemap.xml {
access_log off;
try_files $uri =404;
}
# Custom error page handler
location @error_page {
internal;
rewrite ^ /error?code=$upstream_status redirect;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /error?code=$status;
# SPA fallback - all other routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Block common attacks
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* /(\.git|\.env|\.htaccess|\.htpasswd|node_modules)/ {
deny all;
return 404;
}
# Block sensitive files
location ~* \.(sql|bak|backup|log|ini|conf)$ {
deny all;
return 404;
}
# Access and error logs
access_log /var/log/nginx/ospab.host.access.log;
error_log /var/log/nginx/ospab.host.error.log;
}
# Redirect www to non-www
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name www.ospab.host;
ssl_certificate /etc/letsencrypt/live/ospab.host/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ospab.host/privkey.pem;
return 301 https://ospab.host$request_uri;
}