english version update
This commit is contained in:
154
ospabhost/NGINX_DEPLOY.md
Normal file
154
ospabhost/NGINX_DEPLOY.md
Normal 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`
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) || 'Ошибка подтверждения' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: 'Ошибка получения' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,100 +20,206 @@ import ServerError from './pages/500';
|
||||
import BadGateway from './pages/502';
|
||||
import ServiceUnavailable from './pages/503';
|
||||
import GatewayTimeout from './pages/504';
|
||||
import ErrorPage from './pages/errors';
|
||||
import NetworkError from './pages/errors/NetworkError';
|
||||
import Privateroute from './components/privateroute';
|
||||
import { AuthProvider } from './context/authcontext';
|
||||
import { WebSocketProvider } from './context/WebSocketContext';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { LocaleProvider } from './middleware';
|
||||
import { LocaleProvider, useLocale } from './middleware';
|
||||
|
||||
// SEO конфиг для всех маршрутов
|
||||
// SEO конфиг для всех маршрутов с поддержкой локализации
|
||||
const SEO_CONFIG: Record<string, {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og?: {
|
||||
ru: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
keywords: string;
|
||||
og?: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
en: {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og?: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
}> = {
|
||||
'/': {
|
||||
title: 'Облачное S3 хранилище',
|
||||
description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.',
|
||||
keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage',
|
||||
og: {
|
||||
title: 'ospab.host - Облачное S3 хранилище',
|
||||
description: 'S3-совместимое хранилище с поддержкой 24/7',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/',
|
||||
ru: {
|
||||
title: 'Облачное S3 хранилище',
|
||||
description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.',
|
||||
keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage',
|
||||
og: {
|
||||
title: 'ospab.host - Облачное S3 хранилище',
|
||||
description: 'S3-совместимое хранилище с поддержкой 24/7',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Cloud S3 Storage',
|
||||
description: 'ospab.host - reliable cloud S3-compatible storage in Veliky Novgorod. File storage, backups, media content. 24/7 support tickets, QR authentication.',
|
||||
keywords: 'hosting, cloud storage, S3, file storage, Veliky Novgorod, object storage',
|
||||
og: {
|
||||
title: 'ospab.host - Cloud S3 Storage',
|
||||
description: 'S3-compatible storage with 24/7 support',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/about': {
|
||||
title: 'О компании - Ospab Host',
|
||||
description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.',
|
||||
keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород',
|
||||
og: {
|
||||
title: 'О компании ospab.host',
|
||||
description: 'Современная платформа облачного хранилища',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/about',
|
||||
ru: {
|
||||
title: 'О компании - Ospab Host',
|
||||
description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.',
|
||||
keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород',
|
||||
og: {
|
||||
title: 'О компании ospab.host',
|
||||
description: 'Современная платформа облачного хранилища',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/about',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'About Company - Ospab Host',
|
||||
description: 'Learn about ospab.host - modern cloud storage platform in Veliky Novgorod. S3-compatible storage with support tickets. Founder Georgy Syralyov.',
|
||||
keywords: 'about ospab, hosting history, cloud solutions, S3 storage, Veliky Novgorod',
|
||||
og: {
|
||||
title: 'About ospab.host company',
|
||||
description: 'Modern cloud storage platform',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/about',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/login': {
|
||||
title: 'Вход в аккаунт - Ospab Host',
|
||||
description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.',
|
||||
keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления',
|
||||
og: {
|
||||
title: 'Вход в ospab.host',
|
||||
description: 'Доступ к панели управления',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/login',
|
||||
ru: {
|
||||
title: 'Вход в аккаунт - Ospab Host',
|
||||
description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.',
|
||||
keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления',
|
||||
og: {
|
||||
title: 'Вход в ospab.host',
|
||||
description: 'Доступ к панели управления',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/login',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Login to Account - Ospab Host',
|
||||
description: 'Log in to your ospab.host personal account. Manage storage, support tickets, QR authentication for quick login.',
|
||||
keywords: 'login account, personal account, ospab login, hosting login, QR login, control panel',
|
||||
og: {
|
||||
title: 'Login to ospab.host',
|
||||
description: 'Access to control panel',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/login',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/register': {
|
||||
title: 'Регистрация - Создать аккаунт',
|
||||
description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.',
|
||||
keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт',
|
||||
og: {
|
||||
title: 'Регистрация в ospab.host',
|
||||
description: 'Создайте аккаунт и начните использовать S3 хранилище',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/register',
|
||||
ru: {
|
||||
title: 'Регистрация - Создать аккаунт',
|
||||
description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.',
|
||||
keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт',
|
||||
og: {
|
||||
title: 'Регистрация в ospab.host',
|
||||
description: 'Создайте аккаунт и начните использовать S3 хранилище',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/register',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Registration - Create Account',
|
||||
description: 'Register with ospab.host and start using cloud storage. Create an account for free in 2 minutes, get access to S3 API and support tickets.',
|
||||
keywords: 'registration, create account, ospab registration, hosting registration, new account',
|
||||
og: {
|
||||
title: 'Registration at ospab.host',
|
||||
description: 'Create an account and start using S3 storage',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/register',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/blog': {
|
||||
title: 'Блог о хостинге и S3',
|
||||
description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.',
|
||||
keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage',
|
||||
og: {
|
||||
title: 'Блог ospab.host',
|
||||
description: 'Статьи о хостинге и DevOps',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/blog',
|
||||
ru: {
|
||||
title: 'Блог о хостинге и S3',
|
||||
description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.',
|
||||
keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage',
|
||||
og: {
|
||||
title: 'Блог ospab.host',
|
||||
description: 'Статьи о хостинге и DevOps',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/blog',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Blog about Hosting and S3',
|
||||
description: 'Articles about hosting, S3 storage, cloud technologies, DevOps practices, security. Useful guides from the ospab.host team.',
|
||||
keywords: 'hosting blog, S3 guides, cloud storage, DevOps, object storage',
|
||||
og: {
|
||||
title: 'ospab.host Blog',
|
||||
description: 'Articles about hosting and DevOps',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/blog',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/terms': {
|
||||
title: 'Условия использования',
|
||||
description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.',
|
||||
keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия',
|
||||
og: {
|
||||
title: 'Условия использования ospab.host',
|
||||
description: 'Полные условия использования сервиса',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/terms',
|
||||
ru: {
|
||||
title: 'Условия использования',
|
||||
description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.',
|
||||
keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия',
|
||||
og: {
|
||||
title: 'Условия использования ospab.host',
|
||||
description: 'Полные условия использования сервиса',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/terms',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Terms of Use',
|
||||
description: 'Terms of use for ospab.host service. Read the complete rules for cloud storage users.',
|
||||
keywords: 'terms of use, user agreement, usage rules, legal terms',
|
||||
og: {
|
||||
title: 'ospab.host Terms of Use',
|
||||
description: 'Complete terms of service',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/terms',
|
||||
},
|
||||
},
|
||||
},
|
||||
'/privacy': {
|
||||
title: 'Политика конфиденциальности',
|
||||
description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.',
|
||||
keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных',
|
||||
og: {
|
||||
title: 'Политика конфиденциальности ospab.host',
|
||||
description: 'Защита ваших данных и приватности',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/privacy',
|
||||
ru: {
|
||||
title: 'Политика конфиденциальности',
|
||||
description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.',
|
||||
keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных',
|
||||
og: {
|
||||
title: 'Политика конфиденциальности ospab.host',
|
||||
description: 'Защита ваших данных и приватности',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/privacy',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Privacy Policy',
|
||||
description: 'ospab.host privacy policy. Learn how we protect your personal data, account information and payments. GDPR compliance.',
|
||||
keywords: 'privacy policy, privacy, data protection, GDPR, data security',
|
||||
og: {
|
||||
title: 'ospab.host Privacy Policy',
|
||||
description: 'Protection of your data and privacy',
|
||||
image: 'https://ospab.host/og-image.jpg',
|
||||
url: 'https://ospab.host/privacy',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -121,15 +227,21 @@ const SEO_CONFIG: Record<string, {
|
||||
// Компонент для обновления SEO при изменении маршрута
|
||||
function SEOUpdater() {
|
||||
const location = useLocation();
|
||||
const { locale } = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
const pathname = location.pathname;
|
||||
|
||||
// Получаем SEO данные для текущего маршрута, иначе используем дефолтные
|
||||
const seoData = SEO_CONFIG[pathname] || {
|
||||
title: 'ospab.host - облачный хостинг',
|
||||
description: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.',
|
||||
keywords: 'хостинг, облачный хостинг, VPS, VDS',
|
||||
const seoConfig = SEO_CONFIG[pathname];
|
||||
const seoData = seoConfig ? seoConfig[locale] : {
|
||||
title: locale === 'en' ? 'ospab.host - cloud hosting' : 'ospab.host - облачный хостинг',
|
||||
description: locale === 'en'
|
||||
? 'ospab.host - reliable cloud hosting and virtual machines in Veliky Novgorod.'
|
||||
: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.',
|
||||
keywords: locale === 'en'
|
||||
? 'hosting, cloud hosting, VPS, VDS'
|
||||
: 'хостинг, облачный хостинг, VPS, VDS',
|
||||
};
|
||||
|
||||
// Устанавливаем title
|
||||
@@ -186,7 +298,7 @@ function SEOUpdater() {
|
||||
|
||||
// Скроллим вверх при навигации
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
}, [location.pathname, locale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -256,6 +368,15 @@ function App() {
|
||||
<Route path="/en/502" element={<BadGateway />} />
|
||||
<Route path="/en/503" element={<ServiceUnavailable />} />
|
||||
<Route path="/en/504" element={<GatewayTimeout />} />
|
||||
|
||||
{/* Cloudflare-style error page */}
|
||||
<Route path="/error" element={<ErrorPage />} />
|
||||
<Route path="/en/error" element={<ErrorPage />} />
|
||||
|
||||
{/* Network service page - default for unknown hosts */}
|
||||
<Route path="/1000" element={<NetworkError />} />
|
||||
<Route path="/en/1000" element={<NetworkError />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
interface ErrorPageProps {
|
||||
code: string;
|
||||
@@ -38,10 +40,13 @@ export default function ErrorPage({
|
||||
showBackButton = true,
|
||||
showHomeButton = true,
|
||||
}: ErrorPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-white px-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
{/* Код ошибки */}
|
||||
{/* Error code */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-8xl font-bold text-gray-200 mb-4">{code}</h1>
|
||||
<div className={`inline-flex items-center justify-center w-16 h-16 rounded-full border-2 ${colorClasses[color]}`}>
|
||||
@@ -49,33 +54,33 @@ export default function ErrorPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Описание */}
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 mb-8">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Кнопки */}
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{showHomeButton && (
|
||||
<Link
|
||||
to="/"
|
||||
to={localePath('/')}
|
||||
className={`w-full inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white ${buttonColorClasses[color]} transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
>
|
||||
На главную
|
||||
{t('errors.goHome')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{showLoginButton && (
|
||||
<Link
|
||||
to="/login"
|
||||
to={localePath('/login')}
|
||||
className={`w-full inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white ${buttonColorClasses[color]} transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2`}
|
||||
>
|
||||
Войти
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -84,16 +89,16 @@ export default function ErrorPage({
|
||||
onClick={() => window.history.back()}
|
||||
className="w-full inline-flex items-center justify-center px-6 py-3 border-2 border-gray-300 text-base font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
Назад
|
||||
{t('common.back')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Контактная информация (опционально) */}
|
||||
{/* Contact info (optional) */}
|
||||
{(code === '500' || code === '503') && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500">
|
||||
Если проблема сохраняется, свяжитесь с нами:{' '}
|
||||
{t('footer.contact')}:{' '}
|
||||
<a
|
||||
href="mailto:support@ospab.host"
|
||||
className={`${color === 'red' ? 'text-red-600' : color === 'orange' ? 'text-orange-600' : 'text-gray-600'} hover:underline font-medium`}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { getUnreadCount, getNotifications, markAsRead, type Notification } from '../services/notificationService';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { wsLogger } from '../utils/logger';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
const NotificationBell = () => {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
@@ -10,6 +11,8 @@ const NotificationBell = () => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { subscribe, unsubscribe, isConnected } = useWebSocket();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
// WebSocket обработчик событий
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -115,12 +118,19 @@ const NotificationBell = () => {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'только что';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`;
|
||||
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||
if (isEn) {
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} h ago`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} d ago`;
|
||||
return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' });
|
||||
} else {
|
||||
if (diffInSeconds < 60) return 'только что';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`;
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -129,7 +139,7 @@ const NotificationBell = () => {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
aria-label="Уведомления"
|
||||
aria-label={isEn ? 'Notifications' : 'Уведомления'}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
@@ -165,13 +175,13 @@ const NotificationBell = () => {
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 z-20 max-h-[600px] overflow-hidden flex flex-col">
|
||||
{/* Заголовок */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Уведомления</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-800">{isEn ? 'Notifications' : 'Уведомления'}</h3>
|
||||
<Link
|
||||
to="/dashboard/notifications"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-sm text-ospab-primary hover:underline"
|
||||
>
|
||||
Все
|
||||
{isEn ? 'All' : 'Все'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +196,7 @@ const NotificationBell = () => {
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p>Нет уведомлений</p>
|
||||
<p>{isEn ? 'No notifications' : 'Нет уведомлений'}</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
@@ -240,7 +250,7 @@ const NotificationBell = () => {
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block w-full text-center py-2 text-sm text-ospab-primary hover:bg-gray-50 rounded-md transition-colors"
|
||||
>
|
||||
Показать все уведомления
|
||||
{isEn ? 'Show all notifications' : 'Показать все уведомления'}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuth from '../context/useAuth';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface QRLoginProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -11,17 +12,22 @@ interface QRLoginProps {
|
||||
const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [qrCode, setQrCode] = useState<string>('');
|
||||
const [status, setStatus] = useState<'generating' | 'waiting' | 'scanning' | 'expired' | 'error'>('generating');
|
||||
const [pollingInterval, setPollingInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
||||
const [refreshInterval, setRefreshInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
||||
const [countdownInterval, setCountdownInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const [remaining, setRemaining] = useState<number>(0);
|
||||
const [requestInfo, setRequestInfo] = useState<{ ip?: string; ua?: string } | null>(null);
|
||||
const qrLinkBase = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
useEffect(() => {
|
||||
generateQR();
|
||||
return () => {
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'recharts';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
interface ServerMetricsProps {
|
||||
serverId: number;
|
||||
@@ -58,6 +59,8 @@ interface Summary {
|
||||
}
|
||||
|
||||
export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [period, setPeriod] = useState<'1h' | '6h' | '24h' | '7d' | '30d'>('24h');
|
||||
const [history, setHistory] = useState<MetricData[]>([]);
|
||||
const [current, setCurrent] = useState<CurrentMetrics | null>(null);
|
||||
@@ -78,19 +81,26 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) return `${days}д ${hours}ч`;
|
||||
if (hours > 0) return `${hours}ч ${minutes}м`;
|
||||
return `${minutes}м`;
|
||||
if (isEn) {
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
} else {
|
||||
if (days > 0) return `${days}д ${hours}ч`;
|
||||
if (hours > 0) return `${hours}ч ${minutes}м`;
|
||||
return `${minutes}м`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const localeStr = isEn ? 'en-US' : 'ru-RU';
|
||||
if (period === '1h' || period === '6h') {
|
||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString(localeStr, { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (period === '24h') {
|
||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleTimeString(localeStr, { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric' });
|
||||
return date.toLocaleDateString(localeStr, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +111,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) {
|
||||
throw new Error('Токен не найден. Пожалуйста, войдите снова.');
|
||||
throw new Error(isEn ? 'Token not found. Please log in again.' : 'Токен не найден. Пожалуйста, войдите снова.');
|
||||
}
|
||||
|
||||
// Получаем текущие метрики
|
||||
@@ -130,11 +140,11 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
const error = err as { response?: { status?: number; data?: { error?: string } }; message?: string };
|
||||
console.error('❌ Ошибка загрузки метрик:', error);
|
||||
if (error.response?.status === 401) {
|
||||
setError('Ошибка авторизации. Пожалуйста, войдите снова.');
|
||||
setError(isEn ? 'Authorization error. Please log in again.' : 'Ошибка авторизации. Пожалуйста, войдите снова.');
|
||||
// Можно добавить редирект на логин
|
||||
// window.location.href = '/login';
|
||||
} else {
|
||||
setError(error.response?.data?.error || error.message || 'Ошибка загрузки метрик');
|
||||
setError(error.response?.data?.error || error.message || (isEn ? 'Error loading metrics' : 'Ошибка загрузки метрик'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -152,7 +162,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
if (loading && !current) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Загрузка метрик...</div>
|
||||
<div className="text-gray-500">{isEn ? 'Loading metrics...' : 'Загрузка метрик...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +175,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
onClick={fetchMetrics}
|
||||
className="mt-2 text-sm text-red-600 hover:text-red-800 underline"
|
||||
>
|
||||
Попробовать снова
|
||||
{isEn ? 'Try again' : 'Попробовать снова'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -193,7 +203,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Ср: {summary.cpu.avg.toFixed(1)}% | Макс: {summary.cpu.max.toFixed(1)}%
|
||||
{isEn ? 'Avg' : 'Ср'}: {summary.cpu.avg.toFixed(1)}% | {isEn ? 'Max' : 'Макс'}: {summary.cpu.max.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,7 +211,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
{/* Memory */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">Память</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">{isEn ? 'Memory' : 'Память'}</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{current.memory.usage.toFixed(1)}%
|
||||
@@ -211,7 +221,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Ср: {summary.memory.avg.toFixed(1)}%
|
||||
{isEn ? 'Avg' : 'Ср'}: {summary.memory.avg.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,7 +229,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
{/* Disk */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">Диск</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">{isEn ? 'Disk' : 'Диск'}</h3>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{current.disk.usage.toFixed(1)}%
|
||||
@@ -229,7 +239,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Ср: {summary.disk.avg.toFixed(1)}%
|
||||
{isEn ? 'Avg' : 'Ср'}: {summary.disk.avg.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,7 +247,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
{/* Network */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">Сеть</h3>
|
||||
<h3 className="text-sm font-medium text-gray-600">{isEn ? 'Network' : 'Сеть'}</h3>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
↓ {formatBytes(current.network.in)}
|
||||
@@ -254,7 +264,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
{/* Фильтр периода */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium text-gray-700">Период:</span>
|
||||
<span className="text-sm font-medium text-gray-700">{isEn ? 'Period:' : 'Период:'}</span>
|
||||
{(['1h', '6h', '24h', '7d', '30d'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
@@ -265,7 +275,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{p === '1h' ? '1 час' : p === '6h' ? '6 часов' : p === '24h' ? '24 часа' : p === '7d' ? '7 дней' : '30 дней'}
|
||||
{isEn ? (p === '1h' ? '1 hour' : p === '6h' ? '6 hours' : p === '24h' ? '24 hours' : p === '7d' ? '7 days' : '30 days') : (p === '1h' ? '1 час' : p === '6h' ? '6 часов' : p === '24h' ? '24 часа' : p === '7d' ? '7 дней' : '30 дней')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -275,7 +285,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
<div className="space-y-6">
|
||||
{/* CPU График */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Использование CPU</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{isEn ? 'CPU Usage' : 'Использование CPU'}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={history}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -306,7 +316,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
{/* Memory и Disk */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Использование памяти и диска</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{isEn ? 'Memory and Disk Usage' : 'Использование памяти и диска'}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={history}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -329,14 +339,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
type="monotone"
|
||||
dataKey="memoryUsage"
|
||||
stroke="#3B82F6"
|
||||
name="Память"
|
||||
name={isEn ? 'Memory' : 'Память'}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="diskUsage"
|
||||
stroke="#10B981"
|
||||
name="Диск"
|
||||
name={isEn ? 'Disk' : 'Диск'}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
@@ -345,7 +355,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
|
||||
{/* Network Traffic */}
|
||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Сетевой трафик</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{isEn ? 'Network Traffic' : 'Сетевой трафик'}</h3>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<AreaChart data={history}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -368,14 +378,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
dataKey="networkIn"
|
||||
stroke="#8B5CF6"
|
||||
fill="#C4B5FD"
|
||||
name="Входящий"
|
||||
name={isEn ? 'Incoming' : 'Входящий'}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="networkOut"
|
||||
stroke="#EC4899"
|
||||
fill="#F9A8D4"
|
||||
name="Исходящий"
|
||||
name={isEn ? 'Outgoing' : 'Исходящий'}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -385,18 +395,18 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-lg p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
{loading ? 'Загрузка данных...' : 'Нет данных за выбранный период'}
|
||||
{loading ? (isEn ? 'Loading data...' : 'Загрузка данных...') : (isEn ? 'No data for selected period' : 'Нет данных за выбранный период')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{current ? 'Метрики собираются автоматически каждую минуту' : 'Данные появятся через 1-2 минуты после запуска сервера'}
|
||||
{current ? (isEn ? 'Metrics are collected automatically every minute' : 'Метрики собираются автоматически каждую минуту') : (isEn ? 'Data will appear 1-2 minutes after server start' : 'Данные появятся через 1-2 минуты после запуска сервера')}
|
||||
</p>
|
||||
{current && (
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200 max-w-md mx-auto">
|
||||
<p className="text-sm text-blue-800 font-medium mb-2">💡 Хотите увидеть графики?</p>
|
||||
<p className="text-sm text-blue-800 font-medium mb-2">{isEn ? '💡 Want to see charts?' : '💡 Хотите увидеть графики?'}</p>
|
||||
<p className="text-xs text-blue-700">
|
||||
1. Откройте консоль сервера<br/>
|
||||
2. Запустите: <code className="bg-blue-100 px-2 py-1 rounded">stress-ng --cpu 2 --cpu-load 50 --timeout 180s</code><br/>
|
||||
3. Обновите страницу через 1-2 минуты
|
||||
{isEn ? '1. Open server console' : '1. Откройте консоль сервера'}<br/>
|
||||
{isEn ? '2. Run: ' : '2. Запустите: '}<code className="bg-blue-100 px-2 py-1 rounded">stress-ng --cpu 2 --cpu-load 50 --timeout 180s</code><br/>
|
||||
{isEn ? '3. Refresh page in 1-2 minutes' : '3. Обновите страницу через 1-2 минуты'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -404,7 +414,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||
onClick={fetchMetrics}
|
||||
className="mt-6 px-6 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||
>
|
||||
🔄 Обновить данные
|
||||
{isEn ? '🔄 Refresh data' : '🔄 Обновить данные'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -113,7 +113,7 @@ const ToastItem: React.FC<ToastItemProps> = ({ toast, onClose, index }) => {
|
||||
animation: `toast-enter 0.3s ease-out ${index * 0.1}s both`
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px]`}>
|
||||
<div className={`${styles.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-sm`}>
|
||||
<div className="flex-shrink-0">
|
||||
{styles.icon}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaGithub } from 'react-icons/fa';
|
||||
import logo from '../assets/logo.svg';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-800 text-white py-12">
|
||||
@@ -12,32 +16,32 @@ const Footer = () => {
|
||||
{/* About Section */}
|
||||
<div>
|
||||
<div className="mb-4 flex justify-center md:justify-start">
|
||||
<img src={logo} alt="Логотип" className="h-16 w-auto" width="64" height="64" />
|
||||
<img src={logo} alt="Logo" className="h-20 w-20 rounded-full bg-white p-1.5 shadow-lg" width="80" height="80" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4">О нас</h3>
|
||||
<h3 className="text-xl font-bold mb-4">{t('nav.about')}</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
ospab.host - это надежный хостинг для ваших проектов. Мы предлагаем высокую производительность и круглосуточную поддержку.
|
||||
{t('footer.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-4">Навигация</h3>
|
||||
<h3 className="text-xl font-bold mb-4">{t('footer.links')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/" className="text-gray-400 hover:text-white transition-colors">Главная</Link></li>
|
||||
<li><Link to="/tariffs" className="text-gray-400 hover:text-white transition-colors">Тарифы</Link></li>
|
||||
<li><Link to="/about" className="text-gray-400 hover:text-white transition-colors">О нас</Link></li>
|
||||
<li><Link to="/blog" className="text-gray-400 hover:text-white transition-colors">Блог</Link></li>
|
||||
<li><Link to="/login" className="text-gray-400 hover:text-white transition-colors">Войти</Link></li>
|
||||
<li><Link to={localePath('/')} className="text-gray-400 hover:text-white transition-colors">{t('nav.home')}</Link></li>
|
||||
<li><Link to={localePath('/tariffs')} className="text-gray-400 hover:text-white transition-colors">{t('nav.tariffs')}</Link></li>
|
||||
<li><Link to={localePath('/about')} className="text-gray-400 hover:text-white transition-colors">{t('nav.about')}</Link></li>
|
||||
<li><Link to={localePath('/blog')} className="text-gray-400 hover:text-white transition-colors">{t('nav.blog')}</Link></li>
|
||||
<li><Link to={localePath('/login')} className="text-gray-400 hover:text-white transition-colors">{t('nav.login')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal Documents */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold mb-4">Документы</h3>
|
||||
<h3 className="text-xl font-bold mb-4">{t('footer.legal')}</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/privacy" className="text-gray-400 hover:text-white transition-colors">Политика конфиденциальности</Link></li>
|
||||
<li><Link to="/terms" className="text-gray-400 hover:text-white transition-colors">Условия использования</Link></li>
|
||||
<li><Link to={localePath('/privacy')} className="text-gray-400 hover:text-white transition-colors">{t('footer.privacy')}</Link></li>
|
||||
<li><Link to={localePath('/terms')} className="text-gray-400 hover:text-white transition-colors">{t('footer.terms')}</Link></li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/ospab/ospabhost8.1"
|
||||
@@ -53,10 +57,34 @@ const Footer = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-700 text-center">
|
||||
<div className="mt-8 pt-8 border-t border-gray-700 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
© {currentYear} ospab.host. Все права защищены.
|
||||
© {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
|
||||
</p>
|
||||
|
||||
{/* Language Switcher */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setLocale('ru')}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
locale === 'ru'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocale('en')}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
locale === 'en'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,10 +3,14 @@ import { useState } from 'react';
|
||||
import useAuth from '../context/useAuth';
|
||||
import logo from '../assets/logo.svg';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const Header = () => {
|
||||
const { isLoggedIn, logout } = useAuth();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -18,36 +22,36 @@ const Header = () => {
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link to="/" className="flex items-center">
|
||||
<img src={logo} alt="Логотип" className="h-10 lg:h-14 w-auto mr-2" width="56" height="56" />
|
||||
<Link to={localePath('/')} className="flex items-center">
|
||||
<img src={logo} alt="Logo" className="h-10 lg:h-14 w-auto mr-2" width="56" height="56" />
|
||||
<span className="font-mono text-xl lg:text-2xl text-gray-800 font-bold">ospab.host</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<Link to="/tariffs" className="text-gray-600 hover:text-ospab-primary transition-colors">Тарифы</Link>
|
||||
<Link to="/blog" className="text-gray-600 hover:text-ospab-primary transition-colors">Блог</Link>
|
||||
<Link to="/about" className="text-gray-600 hover:text-ospab-primary transition-colors">О нас</Link>
|
||||
<Link to={localePath('/tariffs')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.tariffs')}</Link>
|
||||
<Link to={localePath('/blog')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.blog')}</Link>
|
||||
<Link to={localePath('/about')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.about')}</Link>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="text-gray-600 hover:text-ospab-primary transition-colors">Личный кабинет</Link>
|
||||
<Link to={localePath('/dashboard')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.dashboard')}</Link>
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-red-500"
|
||||
>
|
||||
Выйти
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="text-gray-600 hover:text-ospab-primary transition-colors">Войти</Link>
|
||||
<Link to={localePath('/login')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.login')}</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
to={localePath('/register')}
|
||||
className="px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent"
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -57,7 +61,7 @@ const Header = () => {
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="md:hidden p-2 text-gray-800"
|
||||
aria-label={isMobileMenuOpen ? "Закрыть меню" : "Открыть меню"}
|
||||
aria-label={isMobileMenuOpen ? t('common.closeMenu') : t('common.openMenu')}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
@@ -74,57 +78,57 @@ const Header = () => {
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden mt-4 pb-4 space-y-2 border-t border-gray-200 pt-4">
|
||||
<Link
|
||||
to="/tariffs"
|
||||
to={localePath('/tariffs')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Тарифы
|
||||
{t('nav.tariffs')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/blog"
|
||||
to={localePath('/blog')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Блог
|
||||
{t('nav.blog')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
to={localePath('/about')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
О нас
|
||||
{t('nav.about')}
|
||||
</Link>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
to={localePath('/dashboard')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Личный кабинет
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left py-2 text-gray-600 hover:text-red-500 transition-colors"
|
||||
>
|
||||
Выйти
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
to={localePath('/login')}
|
||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Войти
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
to={localePath('/register')}
|
||||
className="block w-full text-center mt-2 px-4 py-2 rounded-full text-white font-bold bg-ospab-primary hover:bg-ospab-accent"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
3
ospabhost/frontend/src/i18n/index.ts
Normal file
3
ospabhost/frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useTranslation, getTranslation } from './useTranslation';
|
||||
export type { TranslationKey, TranslationKeys } from './useTranslation';
|
||||
export { ru, en } from './translations';
|
||||
342
ospabhost/frontend/src/i18n/translations/en.ts
Normal file
342
ospabhost/frontend/src/i18n/translations/en.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import type { TranslationKeys } from './ru';
|
||||
|
||||
export const en: TranslationKeys = {
|
||||
// Navigation
|
||||
nav: {
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
tariffs: 'Pricing',
|
||||
blog: 'Blog',
|
||||
login: 'Sign In',
|
||||
register: 'Sign Up',
|
||||
dashboard: 'Dashboard',
|
||||
logout: 'Sign Out',
|
||||
},
|
||||
|
||||
// Home page
|
||||
home: {
|
||||
hero: {
|
||||
title: 'Cloud S3 Storage',
|
||||
subtitle: 'Reliable S3-compatible storage in Veliky Novgorod',
|
||||
description: 'Store files, backups and media content. 24/7 support, flexible plans.',
|
||||
cta: 'Get Started Free',
|
||||
learnMore: 'Learn More',
|
||||
},
|
||||
features: {
|
||||
title: 'Why Choose Us',
|
||||
s3Compatible: {
|
||||
title: 'S3 Compatible',
|
||||
description: 'Fully compatible with AWS S3 API. Use your favorite tools.',
|
||||
},
|
||||
reliability: {
|
||||
title: 'Reliability',
|
||||
description: 'Data replication, backups and 24/7 monitoring.',
|
||||
},
|
||||
speed: {
|
||||
title: 'Speed',
|
||||
description: 'Fast SSD drives and optimized network.',
|
||||
},
|
||||
support: {
|
||||
title: 'Support',
|
||||
description: 'Quick response support tickets. Help available in English.',
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
title: 'Pricing',
|
||||
subtitle: 'Choose the right plan',
|
||||
perMonth: '/month',
|
||||
perGb: 'per GB',
|
||||
storage: 'Storage',
|
||||
traffic: 'Traffic',
|
||||
support: 'Support',
|
||||
selectPlan: 'Select',
|
||||
},
|
||||
cta: {
|
||||
title: 'Ready to get started?',
|
||||
description: 'Join developers who trust us with their data.',
|
||||
},
|
||||
},
|
||||
|
||||
// About page
|
||||
about: {
|
||||
title: 'About Us',
|
||||
subtitle: 'Ospab.host — modern cloud storage platform',
|
||||
story: {
|
||||
title: 'Our Story',
|
||||
text: 'We created ospab.host to provide reliable and affordable cloud storage for businesses and developers.',
|
||||
},
|
||||
team: {
|
||||
title: 'Our Team',
|
||||
founder: 'Founder',
|
||||
},
|
||||
location: {
|
||||
title: 'Location',
|
||||
text: 'Our servers are located in Veliky Novgorod, Russia.',
|
||||
},
|
||||
},
|
||||
|
||||
// Authentication
|
||||
auth: {
|
||||
login: {
|
||||
title: 'Sign In',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
submit: 'Sign In',
|
||||
noAccount: "Don't have an account?",
|
||||
register: 'Sign Up',
|
||||
forgotPassword: 'Forgot password?',
|
||||
orContinueWith: 'or continue with',
|
||||
},
|
||||
register: {
|
||||
title: 'Sign Up',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'Username',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'Email address',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
submit: 'Sign Up',
|
||||
loading: 'Signing up...',
|
||||
hasAccount: 'Already have an account?',
|
||||
haveAccount: 'Already have an account?',
|
||||
login: 'Sign In',
|
||||
loginLink: 'Sign In',
|
||||
orRegisterWith: 'Or sign up with',
|
||||
terms: 'By signing up, you agree to our',
|
||||
termsLink: 'Terms of Service',
|
||||
and: 'and',
|
||||
privacyLink: 'Privacy Policy',
|
||||
success: 'Registration successful! You can now sign in.',
|
||||
captchaRequired: 'Please confirm that you are not a robot.',
|
||||
captchaError: 'Captcha loading error. Please refresh the page.',
|
||||
unknownError: 'Unknown registration error.',
|
||||
networkError: 'Network error. Please try again later.',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
// Email validation
|
||||
emailValidation: {
|
||||
invalidFormat: 'Invalid email format',
|
||||
disposableEmail: 'Disposable email addresses are not allowed',
|
||||
suggestion: 'Did you mean: {email}?',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
invalidCredentials: 'Invalid email or password',
|
||||
emailRequired: 'Email is required',
|
||||
passwordRequired: 'Password is required',
|
||||
passwordTooShort: 'Password must be at least 6 characters',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
usernameRequired: 'Username is required',
|
||||
emailInvalid: 'Invalid email address',
|
||||
},
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
welcome: 'Welcome',
|
||||
sidebar: {
|
||||
overview: 'Overview',
|
||||
storage: 'Storage',
|
||||
buckets: 'Buckets',
|
||||
tickets: 'Tickets',
|
||||
billing: 'Billing',
|
||||
settings: 'Settings',
|
||||
admin: 'Admin',
|
||||
},
|
||||
overview: {
|
||||
balance: 'Balance',
|
||||
storage: 'Used',
|
||||
buckets: 'Buckets',
|
||||
tickets: 'Open Tickets',
|
||||
},
|
||||
storage: {
|
||||
title: 'Storage',
|
||||
createBucket: 'Create Bucket',
|
||||
bucketName: 'Bucket Name',
|
||||
bucketNamePlaceholder: 'my-bucket',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
empty: 'You have no buckets yet',
|
||||
emptyDescription: 'Create your first bucket to store files',
|
||||
},
|
||||
bucket: {
|
||||
files: 'Files',
|
||||
upload: 'Upload',
|
||||
uploadFiles: 'Upload Files',
|
||||
uploadFolder: 'Upload Folder',
|
||||
uploadFromUrl: 'Upload from URL',
|
||||
createFolder: 'Create Folder',
|
||||
delete: 'Delete',
|
||||
download: 'Download',
|
||||
rename: 'Rename',
|
||||
copy: 'Copy',
|
||||
move: 'Move',
|
||||
share: 'Share',
|
||||
properties: 'Properties',
|
||||
emptyBucket: 'Bucket is empty',
|
||||
emptyBucketDescription: 'Upload files or create a folder',
|
||||
dropFilesHere: 'Drop files here',
|
||||
orClickToUpload: 'or click to upload',
|
||||
accessKey: 'Access Key',
|
||||
secretKey: 'Secret Key',
|
||||
endpoint: 'Endpoint',
|
||||
region: 'Region',
|
||||
},
|
||||
tickets: {
|
||||
title: 'Tickets',
|
||||
create: 'Create Ticket',
|
||||
subject: 'Subject',
|
||||
message: 'Message',
|
||||
priority: 'Priority',
|
||||
status: 'Status',
|
||||
created: 'Created',
|
||||
updated: 'Updated',
|
||||
open: 'Open',
|
||||
closed: 'Closed',
|
||||
pending: 'Pending',
|
||||
inProgress: 'In Progress',
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
urgent: 'Urgent',
|
||||
noTickets: 'You have no tickets',
|
||||
noTicketsDescription: 'Create a ticket if you need help',
|
||||
reply: 'Reply',
|
||||
close: 'Close Ticket',
|
||||
reopen: 'Reopen',
|
||||
},
|
||||
billing: {
|
||||
title: 'Billing',
|
||||
balance: 'Balance',
|
||||
topUp: 'Top Up',
|
||||
history: 'Transaction History',
|
||||
date: 'Date',
|
||||
description: 'Description',
|
||||
amount: 'Amount',
|
||||
noTransactions: 'No transactions',
|
||||
uploadCheck: 'Upload Receipt',
|
||||
checkPending: 'Pending Review',
|
||||
checkApproved: 'Approved',
|
||||
checkRejected: 'Rejected',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
profile: 'Profile',
|
||||
security: 'Security',
|
||||
notifications: 'Notifications',
|
||||
appearance: 'Appearance',
|
||||
language: 'Language',
|
||||
timezone: 'Timezone',
|
||||
save: 'Save',
|
||||
changePassword: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
confirmNewPassword: 'Confirm New Password',
|
||||
deleteAccount: 'Delete Account',
|
||||
deleteAccountWarning: 'This action is irreversible. All your data will be deleted.',
|
||||
},
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
error: 'Error',
|
||||
success: 'Success',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
create: 'Create',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
previous: 'Previous',
|
||||
search: 'Search',
|
||||
filter: 'Filter',
|
||||
sort: 'Sort',
|
||||
refresh: 'Refresh',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
or: 'or',
|
||||
and: 'and',
|
||||
of: 'of',
|
||||
items: 'items',
|
||||
bytes: 'bytes',
|
||||
kb: 'KB',
|
||||
mb: 'MB',
|
||||
gb: 'GB',
|
||||
tb: 'TB',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
notFound: 'Page Not Found',
|
||||
notFoundDescription: 'The page you are looking for does not exist or has been removed.',
|
||||
unauthorized: 'Unauthorized',
|
||||
unauthorizedDescription: 'You need to sign in to access this page.',
|
||||
forbidden: 'Access Denied',
|
||||
forbiddenDescription: 'You do not have permission to view this page.',
|
||||
serverError: 'Server Error',
|
||||
serverErrorDescription: 'An internal server error occurred. Please try again later.',
|
||||
badGateway: 'Bad Gateway',
|
||||
badGatewayDescription: 'The server is temporarily unavailable. Please try again later.',
|
||||
serviceUnavailable: 'Service Unavailable',
|
||||
serviceUnavailableDescription: 'The service is temporarily unavailable. Maintenance in progress.',
|
||||
gatewayTimeout: 'Gateway Timeout',
|
||||
gatewayTimeoutDescription: 'The server did not respond in time. Please try again later.',
|
||||
goHome: 'Go Home',
|
||||
tryAgain: 'Try Again',
|
||||
},
|
||||
|
||||
// Footer
|
||||
footer: {
|
||||
description: 'Reliable cloud S3 storage',
|
||||
links: 'Links',
|
||||
legal: 'Legal',
|
||||
terms: 'Terms of Service',
|
||||
privacy: 'Privacy Policy',
|
||||
contact: 'Contact',
|
||||
copyright: '© 2024 ospab.host. All rights reserved.',
|
||||
},
|
||||
|
||||
// Blog
|
||||
blog: {
|
||||
title: 'Blog',
|
||||
subtitle: 'Articles about hosting, S3 and cloud technologies',
|
||||
readMore: 'Read More',
|
||||
published: 'Published',
|
||||
author: 'Author',
|
||||
tags: 'Tags',
|
||||
relatedPosts: 'Related Posts',
|
||||
noPosts: 'No posts yet',
|
||||
},
|
||||
|
||||
// Tariffs
|
||||
tariffs: {
|
||||
title: 'S3 Storage Pricing',
|
||||
subtitle: 'Choose the right plan for your needs',
|
||||
popular: 'Popular',
|
||||
features: 'Features',
|
||||
storage: 'Storage',
|
||||
traffic: 'Outbound Traffic',
|
||||
requests: 'Requests',
|
||||
support: 'Support',
|
||||
api: 'S3 API',
|
||||
included: 'Included',
|
||||
unlimited: 'Unlimited',
|
||||
perMonth: '/month',
|
||||
selectPlan: 'Select Plan',
|
||||
currentPlan: 'Current Plan',
|
||||
contactUs: 'Contact Us',
|
||||
customPlan: 'Need a custom plan?',
|
||||
customPlanDescription: 'Contact us to discuss special requirements.',
|
||||
},
|
||||
};
|
||||
2
ospabhost/frontend/src/i18n/translations/index.ts
Normal file
2
ospabhost/frontend/src/i18n/translations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ru, type TranslationKeys } from './ru';
|
||||
export { en } from './en';
|
||||
342
ospabhost/frontend/src/i18n/translations/ru.ts
Normal file
342
ospabhost/frontend/src/i18n/translations/ru.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
export const ru = {
|
||||
// Навигация
|
||||
nav: {
|
||||
home: 'Главная',
|
||||
about: 'О нас',
|
||||
tariffs: 'Тарифы',
|
||||
blog: 'Блог',
|
||||
login: 'Войти',
|
||||
register: 'Регистрация',
|
||||
dashboard: 'Панель управления',
|
||||
logout: 'Выйти',
|
||||
},
|
||||
|
||||
// Главная страница
|
||||
home: {
|
||||
hero: {
|
||||
title: 'Облачное S3 хранилище',
|
||||
subtitle: 'Надёжное S3-совместимое хранилище в Великом Новгороде',
|
||||
description: 'Храните файлы, резервные копии и медиа-контент. Поддержка 24/7, гибкие тарифы.',
|
||||
cta: 'Начать бесплатно',
|
||||
learnMore: 'Узнать больше',
|
||||
},
|
||||
features: {
|
||||
title: 'Почему выбирают нас',
|
||||
s3Compatible: {
|
||||
title: 'S3-совместимость',
|
||||
description: 'Полная совместимость с AWS S3 API. Используйте привычные инструменты.',
|
||||
},
|
||||
reliability: {
|
||||
title: 'Надёжность',
|
||||
description: 'Репликация данных, резервное копирование и мониторинг 24/7.',
|
||||
},
|
||||
speed: {
|
||||
title: 'Скорость',
|
||||
description: 'Быстрые SSD-накопители и оптимизированная сеть.',
|
||||
},
|
||||
support: {
|
||||
title: 'Поддержка',
|
||||
description: 'Тикеты поддержки с быстрым откликом. Помощь на русском языке.',
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
title: 'Тарифы',
|
||||
subtitle: 'Выберите подходящий план',
|
||||
perMonth: '/месяц',
|
||||
perGb: 'за ГБ',
|
||||
storage: 'Хранилище',
|
||||
traffic: 'Трафик',
|
||||
support: 'Поддержка',
|
||||
selectPlan: 'Выбрать',
|
||||
},
|
||||
cta: {
|
||||
title: 'Готовы начать?',
|
||||
description: 'Присоединяйтесь к разработчикам, которые доверяют нам свои данные.',
|
||||
},
|
||||
},
|
||||
|
||||
// Страница о нас
|
||||
about: {
|
||||
title: 'О компании',
|
||||
subtitle: 'Ospab.host — современная платформа облачного хранилища',
|
||||
story: {
|
||||
title: 'Наша история',
|
||||
text: 'Мы создали ospab.host чтобы предоставить надёжное и доступное облачное хранилище для бизнеса и разработчиков.',
|
||||
},
|
||||
team: {
|
||||
title: 'Наша команда',
|
||||
founder: 'Основатель',
|
||||
},
|
||||
location: {
|
||||
title: 'Расположение',
|
||||
text: 'Наши серверы расположены в Великом Новгороде, Россия.',
|
||||
},
|
||||
},
|
||||
|
||||
// Авторизация
|
||||
auth: {
|
||||
login: {
|
||||
title: 'Вход в аккаунт',
|
||||
email: 'Email',
|
||||
password: 'Пароль',
|
||||
submit: 'Войти',
|
||||
noAccount: 'Нет аккаунта?',
|
||||
register: 'Зарегистрироваться',
|
||||
forgotPassword: 'Забыли пароль?',
|
||||
orContinueWith: 'или продолжить с',
|
||||
},
|
||||
register: {
|
||||
title: 'Регистрация',
|
||||
username: 'Имя пользователя',
|
||||
usernamePlaceholder: 'Имя пользователя',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'Электронная почта',
|
||||
password: 'Пароль',
|
||||
passwordPlaceholder: 'Пароль',
|
||||
confirmPassword: 'Подтвердите пароль',
|
||||
submit: 'Зарегистрироваться',
|
||||
loading: 'Регистрируем...',
|
||||
hasAccount: 'Уже есть аккаунт?',
|
||||
haveAccount: 'Уже есть аккаунт?',
|
||||
login: 'Войти',
|
||||
loginLink: 'Войти',
|
||||
orRegisterWith: 'Или зарегистрироваться через',
|
||||
terms: 'Регистрируясь, вы соглашаетесь с',
|
||||
termsLink: 'условиями использования',
|
||||
and: 'и',
|
||||
privacyLink: 'политикой конфиденциальности',
|
||||
success: 'Регистрация прошла успешно! Теперь вы можете войти.',
|
||||
captchaRequired: 'Пожалуйста, подтвердите, что вы не робот.',
|
||||
captchaError: 'Ошибка загрузки капчи. Попробуйте обновить страницу.',
|
||||
unknownError: 'Неизвестная ошибка регистрации.',
|
||||
networkError: 'Произошла ошибка сети. Пожалуйста, попробуйте позже.',
|
||||
invalidEmail: 'Введите корректный email адрес',
|
||||
// Email validation
|
||||
emailValidation: {
|
||||
invalidFormat: 'Неверный формат email адреса',
|
||||
disposableEmail: 'Временные email адреса не допускаются',
|
||||
suggestion: 'Возможно, вы имели в виду: {email}?',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
invalidCredentials: 'Неверный email или пароль',
|
||||
emailRequired: 'Email обязателен',
|
||||
passwordRequired: 'Пароль обязателен',
|
||||
passwordTooShort: 'Пароль должен быть минимум 6 символов',
|
||||
passwordsDoNotMatch: 'Пароли не совпадают',
|
||||
usernameRequired: 'Имя пользователя обязательно',
|
||||
emailInvalid: 'Некорректный email',
|
||||
},
|
||||
},
|
||||
|
||||
// Дашборд
|
||||
dashboard: {
|
||||
title: 'Панель управления',
|
||||
welcome: 'Добро пожаловать',
|
||||
sidebar: {
|
||||
overview: 'Обзор',
|
||||
storage: 'Хранилище',
|
||||
buckets: 'Бакеты',
|
||||
tickets: 'Тикеты',
|
||||
billing: 'Биллинг',
|
||||
settings: 'Настройки',
|
||||
admin: 'Админ',
|
||||
},
|
||||
overview: {
|
||||
balance: 'Баланс',
|
||||
storage: 'Использовано',
|
||||
buckets: 'Бакетов',
|
||||
tickets: 'Открытых тикетов',
|
||||
},
|
||||
storage: {
|
||||
title: 'Хранилище',
|
||||
createBucket: 'Создать бакет',
|
||||
bucketName: 'Название бакета',
|
||||
bucketNamePlaceholder: 'my-bucket',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
empty: 'У вас пока нет бакетов',
|
||||
emptyDescription: 'Создайте первый бакет для хранения файлов',
|
||||
},
|
||||
bucket: {
|
||||
files: 'Файлы',
|
||||
upload: 'Загрузить',
|
||||
uploadFiles: 'Загрузить файлы',
|
||||
uploadFolder: 'Загрузить папку',
|
||||
uploadFromUrl: 'Загрузить по URL',
|
||||
createFolder: 'Создать папку',
|
||||
delete: 'Удалить',
|
||||
download: 'Скачать',
|
||||
rename: 'Переименовать',
|
||||
copy: 'Копировать',
|
||||
move: 'Переместить',
|
||||
share: 'Поделиться',
|
||||
properties: 'Свойства',
|
||||
emptyBucket: 'Бакет пуст',
|
||||
emptyBucketDescription: 'Загрузите файлы или создайте папку',
|
||||
dropFilesHere: 'Перетащите файлы сюда',
|
||||
orClickToUpload: 'или нажмите для выбора',
|
||||
accessKey: 'Ключ доступа',
|
||||
secretKey: 'Секретный ключ',
|
||||
endpoint: 'Endpoint',
|
||||
region: 'Регион',
|
||||
},
|
||||
tickets: {
|
||||
title: 'Тикеты',
|
||||
create: 'Создать тикет',
|
||||
subject: 'Тема',
|
||||
message: 'Сообщение',
|
||||
priority: 'Приоритет',
|
||||
status: 'Статус',
|
||||
created: 'Создан',
|
||||
updated: 'Обновлён',
|
||||
open: 'Открыт',
|
||||
closed: 'Закрыт',
|
||||
pending: 'В ожидании',
|
||||
inProgress: 'В работе',
|
||||
low: 'Низкий',
|
||||
medium: 'Средний',
|
||||
high: 'Высокий',
|
||||
urgent: 'Срочный',
|
||||
noTickets: 'У вас нет тикетов',
|
||||
noTicketsDescription: 'Создайте тикет если вам нужна помощь',
|
||||
reply: 'Ответить',
|
||||
close: 'Закрыть тикет',
|
||||
reopen: 'Открыть заново',
|
||||
},
|
||||
billing: {
|
||||
title: 'Биллинг',
|
||||
balance: 'Баланс',
|
||||
topUp: 'Пополнить',
|
||||
history: 'История операций',
|
||||
date: 'Дата',
|
||||
description: 'Описание',
|
||||
amount: 'Сумма',
|
||||
noTransactions: 'Нет транзакций',
|
||||
uploadCheck: 'Загрузить чек',
|
||||
checkPending: 'На проверке',
|
||||
checkApproved: 'Одобрен',
|
||||
checkRejected: 'Отклонён',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
profile: 'Профиль',
|
||||
security: 'Безопасность',
|
||||
notifications: 'Уведомления',
|
||||
appearance: 'Внешний вид',
|
||||
language: 'Язык',
|
||||
timezone: 'Часовой пояс',
|
||||
save: 'Сохранить',
|
||||
changePassword: 'Сменить пароль',
|
||||
currentPassword: 'Текущий пароль',
|
||||
newPassword: 'Новый пароль',
|
||||
confirmNewPassword: 'Подтвердите новый пароль',
|
||||
deleteAccount: 'Удалить аккаунт',
|
||||
deleteAccountWarning: 'Это действие необратимо. Все ваши данные будут удалены.',
|
||||
},
|
||||
},
|
||||
|
||||
// Общие
|
||||
common: {
|
||||
loading: 'Загрузка...',
|
||||
error: 'Ошибка',
|
||||
success: 'Успешно',
|
||||
save: 'Сохранить',
|
||||
cancel: 'Отмена',
|
||||
delete: 'Удалить',
|
||||
edit: 'Редактировать',
|
||||
create: 'Создать',
|
||||
close: 'Закрыть',
|
||||
confirm: 'Подтвердить',
|
||||
back: 'Назад',
|
||||
next: 'Далее',
|
||||
previous: 'Назад',
|
||||
search: 'Поиск',
|
||||
filter: 'Фильтр',
|
||||
sort: 'Сортировка',
|
||||
refresh: 'Обновить',
|
||||
download: 'Скачать',
|
||||
upload: 'Загрузить',
|
||||
copy: 'Копировать',
|
||||
copied: 'Скопировано',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
or: 'или',
|
||||
and: 'и',
|
||||
of: 'из',
|
||||
items: 'элементов',
|
||||
bytes: 'байт',
|
||||
kb: 'КБ',
|
||||
mb: 'МБ',
|
||||
gb: 'ГБ',
|
||||
tb: 'ТБ',
|
||||
openMenu: 'Открыть меню',
|
||||
closeMenu: 'Закрыть меню',
|
||||
},
|
||||
|
||||
// Ошибки
|
||||
errors: {
|
||||
notFound: 'Страница не найдена',
|
||||
notFoundDescription: 'Запрашиваемая страница не существует или была удалена.',
|
||||
unauthorized: 'Не авторизован',
|
||||
unauthorizedDescription: 'Для доступа к этой странице необходимо войти в аккаунт.',
|
||||
forbidden: 'Доступ запрещён',
|
||||
forbiddenDescription: 'У вас нет прав для просмотра этой страницы.',
|
||||
serverError: 'Ошибка сервера',
|
||||
serverErrorDescription: 'Произошла внутренняя ошибка сервера. Попробуйте позже.',
|
||||
badGateway: 'Плохой шлюз',
|
||||
badGatewayDescription: 'Сервер временно недоступен. Попробуйте позже.',
|
||||
serviceUnavailable: 'Сервис недоступен',
|
||||
serviceUnavailableDescription: 'Сервис временно недоступен. Ведутся технические работы.',
|
||||
gatewayTimeout: 'Превышено время ожидания',
|
||||
gatewayTimeoutDescription: 'Сервер не ответил вовремя. Попробуйте позже.',
|
||||
goHome: 'На главную',
|
||||
tryAgain: 'Попробовать снова',
|
||||
},
|
||||
|
||||
// Футер
|
||||
footer: {
|
||||
description: 'Надёжное облачное S3 хранилище',
|
||||
links: 'Ссылки',
|
||||
legal: 'Правовая информация',
|
||||
terms: 'Условия использования',
|
||||
privacy: 'Политика конфиденциальности',
|
||||
contact: 'Контакты',
|
||||
copyright: '© 2024 ospab.host. Все права защищены.',
|
||||
},
|
||||
|
||||
// Блог
|
||||
blog: {
|
||||
title: 'Блог',
|
||||
subtitle: 'Статьи о хостинге, S3 и облачных технологиях',
|
||||
readMore: 'Читать далее',
|
||||
published: 'Опубликовано',
|
||||
author: 'Автор',
|
||||
tags: 'Теги',
|
||||
relatedPosts: 'Похожие статьи',
|
||||
noPosts: 'Пока нет статей',
|
||||
},
|
||||
|
||||
// Тарифы
|
||||
tariffs: {
|
||||
title: 'Тарифы S3 хранилища',
|
||||
subtitle: 'Выберите подходящий план для ваших задач',
|
||||
popular: 'Популярный',
|
||||
features: 'Возможности',
|
||||
storage: 'Хранилище',
|
||||
traffic: 'Исходящий трафик',
|
||||
requests: 'Запросов',
|
||||
support: 'Поддержка',
|
||||
api: 'S3 API',
|
||||
included: 'Включено',
|
||||
unlimited: 'Безлимитно',
|
||||
perMonth: '/месяц',
|
||||
selectPlan: 'Выбрать план',
|
||||
currentPlan: 'Текущий план',
|
||||
contactUs: 'Связаться с нами',
|
||||
customPlan: 'Нужен индивидуальный план?',
|
||||
customPlanDescription: 'Свяжитесь с нами для обсуждения особых условий.',
|
||||
},
|
||||
};
|
||||
|
||||
export type TranslationKeys = typeof ru;
|
||||
92
ospabhost/frontend/src/i18n/useTranslation.ts
Normal file
92
ospabhost/frontend/src/i18n/useTranslation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useLocale } from '../middleware';
|
||||
import { ru, en, type TranslationKeys } from './translations';
|
||||
import type { Locale } from '../middleware/locale.utils';
|
||||
|
||||
// Словарь переводов
|
||||
const translations: Record<Locale, TranslationKeys> = {
|
||||
ru,
|
||||
en,
|
||||
};
|
||||
|
||||
type NestedKeyOf<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: K extends string
|
||||
? T[K] extends object
|
||||
? `${K}` | `${K}.${NestedKeyOf<T[K]>}`
|
||||
: `${K}`
|
||||
: never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
type TranslationKey = NestedKeyOf<TranslationKeys>;
|
||||
|
||||
/**
|
||||
* Получить значение по вложенному ключу
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): string {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return path; // Возвращаем ключ если перевод не найден
|
||||
}
|
||||
}
|
||||
|
||||
return typeof current === 'string' ? current : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения переводов
|
||||
*/
|
||||
export function useTranslation() {
|
||||
const { locale, setLocale } = useLocale();
|
||||
|
||||
const t = useCallback(
|
||||
(key: TranslationKey, params?: Record<string, string | number>): string => {
|
||||
const translation = getNestedValue(
|
||||
translations[locale] as unknown as Record<string, unknown>,
|
||||
key
|
||||
);
|
||||
|
||||
if (!params) return translation;
|
||||
|
||||
// Замена параметров {{param}}
|
||||
return translation.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => {
|
||||
return params[paramKey]?.toString() ?? `{{${paramKey}}}`;
|
||||
});
|
||||
},
|
||||
[locale]
|
||||
);
|
||||
|
||||
// Получить объект переводов для секции
|
||||
const tSection = useCallback(
|
||||
<K extends keyof TranslationKeys>(section: K): TranslationKeys[K] => {
|
||||
return translations[locale][section];
|
||||
},
|
||||
[locale]
|
||||
);
|
||||
|
||||
// Текущие переводы
|
||||
const translations_current = useMemo(() => translations[locale], [locale]);
|
||||
|
||||
return {
|
||||
t,
|
||||
tSection,
|
||||
locale,
|
||||
setLocale,
|
||||
translations: translations_current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить перевод без хука (для использования вне компонентов)
|
||||
*/
|
||||
export function getTranslation(locale: Locale, key: string): string {
|
||||
return getNestedValue(translations[locale] as unknown as Record<string, unknown>, key);
|
||||
}
|
||||
|
||||
export type { TranslationKey, TranslationKeys };
|
||||
@@ -2,6 +2,244 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */
|
||||
|
||||
/* Улучшенный перенос слов для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.prose {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.prose p, .prose li {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Предотвращение горизонтальной прокрутки */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Адаптация длинных слов в интерфейсе */
|
||||
.break-word-mobile {
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Усечение текста с троеточием на мобильных */
|
||||
.truncate-mobile {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line clamp utilities для совместимости */
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* ===== ГЛОБАЛЬНЫЕ АНИМАЦИИ ===== */
|
||||
|
||||
/* Fade in снизу */
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in сверху */
|
||||
@keyframes fade-in-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in слева */
|
||||
@keyframes fade-in-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade in справа */
|
||||
@keyframes fade-in-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale in */
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce subtle */
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Float */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse glow */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide in from bottom for cards */
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Классы анимаций */
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
animation: fade-in-down 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-left {
|
||||
animation: fade-in-left 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-right {
|
||||
animation: fade-in-right 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Задержки анимаций */
|
||||
.animation-delay-100 { animation-delay: 0.1s; }
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-300 { animation-delay: 0.3s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-500 { animation-delay: 0.5s; }
|
||||
.animation-delay-600 { animation-delay: 0.6s; }
|
||||
.animation-delay-700 { animation-delay: 0.7s; }
|
||||
.animation-delay-800 { animation-delay: 0.8s; }
|
||||
|
||||
/* Начальное состояние для анимируемых элементов */
|
||||
.animate-on-scroll {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Hover эффекты для карточек */
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Hover эффект для кнопок */
|
||||
.btn-hover {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.btn-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.btn-hover:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ===== СУЩЕСТВУЮЩИЕ АНИМАЦИИ ===== */
|
||||
|
||||
/* Анимации для модальных окон и уведомлений */
|
||||
@keyframes modal-enter {
|
||||
from {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function Unauthorized() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="401"
|
||||
title="Требуется авторизация"
|
||||
description="Для доступа к этому ресурсу необходимо войти в систему."
|
||||
title={t('errors.unauthorized')}
|
||||
description={t('errors.unauthorizedDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function Forbidden() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="403"
|
||||
title="Доступ запрещён"
|
||||
description="У вас недостаточно прав для доступа к этой странице. Обратитесь к администратору, если считаете это ошибкой."
|
||||
title={t('errors.forbidden')}
|
||||
description={t('errors.forbiddenDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
code="404"
|
||||
title="Страница не найдена"
|
||||
description="К сожалению, запрашиваемая страница не существует или была перемещена."
|
||||
title={t('errors.notFound')}
|
||||
description={t('errors.notFoundDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function ServerError() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="500"
|
||||
title="Ошибка сервера"
|
||||
description="На сервере произошла ошибка. Мы уже работаем над её устранением. Попробуйте обновить страницу или вернитесь позже."
|
||||
title={t('errors.serverError')}
|
||||
description={t('errors.serverErrorDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function BadGateway() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="502"
|
||||
title="Неверный шлюз"
|
||||
description="Сервер получил недействительный ответ от вышестоящего сервера. Это временная проблема, попробуйте обновить страницу."
|
||||
title={t('errors.badGateway')}
|
||||
description={t('errors.badGatewayDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function ServiceUnavailable() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="503"
|
||||
title="Сервис недоступен"
|
||||
description="Сервер временно не может обработать запрос. Возможно, проводятся технические работы. Пожалуйста, попробуйте позже."
|
||||
title={t('errors.serviceUnavailable')}
|
||||
description={t('errors.serviceUnavailableDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import ErrorPage from '../components/ErrorPage';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
export default function GatewayTimeout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ErrorPage
|
||||
code="504"
|
||||
title="Превышено время ожидания"
|
||||
description="Сервер не дождался ответа от вышестоящего сервера. Это может быть вызвано временными проблемами с сетью."
|
||||
title={t('errors.gatewayTimeout')}
|
||||
description={t('errors.gatewayTimeoutDescription')}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { FaRocket, FaUsers, FaShieldAlt, FaChartLine, FaHeart, FaServer, FaGithub } from 'react-icons/fa';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const AboutPage = () => {
|
||||
const { locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
@@ -13,10 +19,10 @@ const AboutPage = () => {
|
||||
<div className="container mx-auto max-w-6xl relative z-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold mb-6 leading-tight">
|
||||
История ospab.host
|
||||
{isEn ? 'The Story of ospab.host' : 'История ospab.host'}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-blue-100 max-w-3xl mx-auto">
|
||||
Первый дата-центр в Великом Новгороде.
|
||||
{isEn ? 'The first data center in Veliky Novgorod.' : 'Первый дата-центр в Великом Новгороде.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,7 +36,7 @@ const AboutPage = () => {
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src="/me.jpg"
|
||||
alt="Георгий, основатель ospab.host"
|
||||
alt={isEn ? 'Georgy, founder of ospab.host' : 'Георгий, основатель ospab.host'}
|
||||
className="w-48 h-48 md:w-56 md:h-56 rounded-2xl shadow-2xl border-4 border-ospab-primary object-cover"
|
||||
width="224"
|
||||
height="224"
|
||||
@@ -39,23 +45,23 @@ const AboutPage = () => {
|
||||
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-3">Георгий</h2>
|
||||
<p className="text-xl text-ospab-primary font-semibold mb-2">Основатель и CEO</p>
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-3">{isEn ? 'Georgy' : 'Георгий'}</h2>
|
||||
<p className="text-xl text-ospab-primary font-semibold mb-2">{isEn ? 'Founder & CEO' : 'Основатель и CEO'}</p>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-gray-600">
|
||||
<span className="flex items-center gap-2">
|
||||
<FaUsers className="text-ospab-primary" />
|
||||
13 лет
|
||||
{isEn ? '13 years old' : '13 лет'}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<FaServer className="text-ospab-primary" />
|
||||
Великий Новгород
|
||||
{isEn ? 'Veliky Novgorod' : 'Великий Новгород'}
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/ospab/ospabhost8.1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:text-ospab-primary transition-colors"
|
||||
title="Исходный код проекта"
|
||||
title={isEn ? 'Project source code' : 'Исходный код проекта'}
|
||||
>
|
||||
<FaGithub className="text-ospab-primary" />
|
||||
GitHub
|
||||
@@ -64,9 +70,9 @@ const AboutPage = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
В 13 лет я решил создать то, чего не было в моём городе — современный дата-центр.
|
||||
Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту
|
||||
в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.
|
||||
{isEn
|
||||
? "At 13, I decided to create something that didn't exist in my city — a modern data center. Starting with learning technologies and working on my first hosting, I'm gradually turning a dream into reality. With the help of an investor friend, we're building the infrastructure of the future for Veliky Novgorod."
|
||||
: 'В 13 лет я решил создать то, чего не было в моём городе — современный дата-центр. Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,42 +84,43 @@ const AboutPage = () => {
|
||||
<section className="py-20 px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-center text-gray-900 mb-12">
|
||||
Наша история
|
||||
{isEn ? 'Our Story' : 'Наша история'}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-white p-8 rounded-2xl border-l-4 border-ospab-primary shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<FaRocket className="text-ospab-primary" />
|
||||
Сентябрь 2025 — Начало пути
|
||||
{isEn ? 'September 2025 — The Beginning' : 'Сентябрь 2025 — Начало пути'}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Всё началось с простой идеи: создать место, где любой сможет разместить свой проект,
|
||||
сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает
|
||||
свой дата-центр, и я решил взяться за эту задачу.
|
||||
{isEn
|
||||
? "It all started with a simple idea: create a place where anyone can host their project, website, or server with maximum reliability and speed. Veliky Novgorod deserves its own data center, and I decided to take on this task."
|
||||
: 'Всё началось с простой идеи: создать место, где любой сможет разместить свой проект, сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает свой дата-центр, и я решил взяться за эту задачу.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-purple-50 to-white p-8 rounded-2xl border-l-4 border-ospab-accent shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<FaHeart className="text-ospab-accent" />
|
||||
Поддержка и развитие
|
||||
{isEn ? 'Support and Development' : 'Поддержка и развитие'}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры.
|
||||
Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг,
|
||||
а поддержка всегда рядом.
|
||||
{isEn
|
||||
? "My investor friend believed in the project and helps with infrastructure development. We're building not just a business, but a community where every client is like a friend, and support is always nearby."
|
||||
: 'Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры. Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг, а поддержка всегда рядом.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-50 to-white p-8 rounded-2xl border-l-4 border-green-500 shadow-lg">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||
<FaChartLine className="text-green-500" />
|
||||
Настоящее и будущее
|
||||
{isEn ? 'Present and Future' : 'Настоящее и будущее'}
|
||||
</h3>
|
||||
<p className="text-lg text-gray-700 leading-relaxed">
|
||||
Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД.
|
||||
ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.
|
||||
{isEn
|
||||
? "Currently, we're actively working on hosting and preparing infrastructure for the future data center. ospab.host is the first step towards the digital future of Veliky Novgorod, and we're just getting started."
|
||||
: 'Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД. ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,10 +132,12 @@ const AboutPage = () => {
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
Наша миссия
|
||||
{isEn ? 'Our Mission' : 'Наша миссия'}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города
|
||||
{isEn
|
||||
? "Make quality hosting accessible to everyone, and the data center — the city's pride"
|
||||
: 'Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -137,9 +146,11 @@ const AboutPage = () => {
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<FaServer className="text-3xl text-ospab-primary" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Современные технологии</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Modern Technologies' : 'Современные технологии'}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Используем новейшее оборудование и программное обеспечение для максимальной производительности
|
||||
{isEn
|
||||
? 'We use the latest equipment and software for maximum performance'
|
||||
: 'Используем новейшее оборудование и программное обеспечение для максимальной производительности'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -147,9 +158,11 @@ const AboutPage = () => {
|
||||
<div className="w-16 h-16 bg-pink-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<FaShieldAlt className="text-3xl text-ospab-accent" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Безопасность данных</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Data Security' : 'Безопасность данных'}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7
|
||||
{isEn
|
||||
? 'Customer data protection is our priority. Regular backups and 24/7 monitoring'
|
||||
: 'Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -157,9 +170,11 @@ const AboutPage = () => {
|
||||
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<FaUsers className="text-3xl text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Личная поддержка</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Personal Support' : 'Личная поддержка'}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Каждый клиент получает персональное внимание и помощь от основателя
|
||||
{isEn
|
||||
? 'Every customer receives personal attention and help from the founder'
|
||||
: 'Каждый клиент получает персональное внимание и помощь от основателя'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +186,7 @@ const AboutPage = () => {
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="bg-gradient-to-br from-ospab-primary to-blue-700 rounded-3xl shadow-2xl p-12 md:p-16 text-white">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-center mb-12">
|
||||
Почему выбирают ospab.host?
|
||||
{isEn ? 'Why choose ospab.host?' : 'Почему выбирают ospab.host?'}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
@@ -180,8 +195,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Первый ЦОД в городе</h4>
|
||||
<p className="text-blue-100">Мы создаём историю Великого Новгорода</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'First data center in the city' : 'Первый ЦОД в городе'}</h4>
|
||||
<p className="text-blue-100">{isEn ? "We're making Veliky Novgorod history" : 'Мы создаём историю Великого Новгорода'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,8 +205,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Доступные тарифы</h4>
|
||||
<p className="text-blue-100">Качественный хостинг для всех без переплат</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Affordable pricing' : 'Доступные тарифы'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'Quality hosting for everyone without overpaying' : 'Качественный хостинг для всех без переплат'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,8 +215,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Быстрая поддержка</h4>
|
||||
<p className="text-blue-100">Ответим на вопросы в любое время</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Fast support' : 'Быстрая поддержка'}</h4>
|
||||
<p className="text-blue-100">{isEn ? "We'll answer questions anytime" : 'Ответим на вопросы в любое время'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -210,8 +225,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Прозрачность</h4>
|
||||
<p className="text-blue-100">Честно о возможностях и ограничениях</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Transparency' : 'Прозрачность'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'Honest about capabilities and limitations' : 'Честно о возможностях и ограничениях'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,8 +235,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Современная инфраструктура</h4>
|
||||
<p className="text-blue-100">Актуальное ПО и оборудование</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'Modern infrastructure' : 'Современная инфраструктура'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'Up-to-date software and equipment' : 'Актуальное ПО и оборудование'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -230,8 +245,8 @@ const AboutPage = () => {
|
||||
<span className="text-white font-bold">✓</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg mb-2">Мечта становится реальностью</h4>
|
||||
<p className="text-blue-100">История, которой можно гордиться</p>
|
||||
<h4 className="font-bold text-lg mb-2">{isEn ? 'A dream becoming reality' : 'Мечта становится реальностью'}</h4>
|
||||
<p className="text-blue-100">{isEn ? 'A story to be proud of' : 'История, которой можно гордиться'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,7 +263,7 @@ const AboutPage = () => {
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
Исходный код на GitHub
|
||||
{isEn ? 'Source code on GitHub' : 'Исходный код на GitHub'}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -262,23 +277,25 @@ const AboutPage = () => {
|
||||
<section className="py-20 px-4 bg-gray-50">
|
||||
<div className="container mx-auto max-w-4xl text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
Станьте частью истории
|
||||
{isEn ? 'Become part of history' : 'Станьте частью истории'}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10">
|
||||
Присоединяйтесь к ospab.host и помогите создать цифровое будущее Великого Новгорода
|
||||
{isEn
|
||||
? 'Join ospab.host and help create the digital future of Veliky Novgorod'
|
||||
: 'Присоединяйтесь к ospab.host и помогите создать цифровое будущее Великого Новгорода'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/register"
|
||||
href={localePath('/register')}
|
||||
className="px-8 py-4 bg-ospab-primary hover:bg-blue-700 text-white font-bold text-lg rounded-full transition-all transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
Начать бесплатно
|
||||
{isEn ? 'Start for free' : 'Начать бесплатно'}
|
||||
</a>
|
||||
<a
|
||||
href="/tariffs"
|
||||
href={localePath('/tariffs')}
|
||||
className="px-8 py-4 bg-white hover:bg-gray-50 text-ospab-primary font-bold text-lg rounded-full border-2 border-ospab-primary transition-all transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
Посмотреть тарифы
|
||||
{isEn ? 'View plans' : 'Посмотреть тарифы'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
@@ -25,6 +27,8 @@ const Blog: React.FC = () => {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
@@ -37,8 +41,8 @@ const Blog: React.FC = () => {
|
||||
setPosts(response.data.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки постов:', err);
|
||||
setError('Не удалось загрузить статьи');
|
||||
console.error('Error loading posts:', err);
|
||||
setError(locale === 'en' ? 'Failed to load articles' : 'Не удалось загрузить статьи');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,7 +50,7 @@ const Blog: React.FC = () => {
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
return date.toLocaleDateString(locale === 'en' ? 'en-US' : 'ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
@@ -56,7 +60,7 @@ const Blog: React.FC = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-xl text-gray-600">Загрузка...</div>
|
||||
<div className="text-xl text-gray-600">{t('common.loading')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,9 +70,9 @@ const Blog: React.FC = () => {
|
||||
<div className="container mx-auto px-4 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">Блог</h1>
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-4">{t('blog.title')}</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Новости, статьи и полезные материалы о хостинге
|
||||
{t('blog.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,14 +86,14 @@ const Blog: React.FC = () => {
|
||||
{/* Posts Grid */}
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-2xl text-gray-400">📭 Статей пока нет</p>
|
||||
<p className="text-2xl text-gray-400">📭 {t('blog.noPosts')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{posts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to={`/blog/${post.url}`}
|
||||
to={localePath(`/blog/${post.url}`)}
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow overflow-hidden group"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
@@ -103,7 +107,7 @@ const Blog: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-48 bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<span className="text-4xl text-white font-bold">Статья</span>
|
||||
<span className="text-4xl text-white font-bold">{locale === 'en' ? 'Article' : 'Статья'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -122,9 +126,8 @@ const Blog: React.FC = () => {
|
||||
{/* Meta */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Автор: {post.author.username}</span>
|
||||
<span>Просмотров: {post.views}</span>
|
||||
<span>Комментариев: {post._count.comments}</span>
|
||||
<span className="truncate max-w-[150px]" title={post.author.username}>{t('blog.author')}: {post.author.username}</span>
|
||||
<span>{locale === 'en' ? 'Views' : 'Просмотров'}: {post.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ const BlogPost: React.FC = () => {
|
||||
<div className="flex items-center gap-6 text-gray-500 mb-8 pb-6 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Автор:</span>
|
||||
<span>{post.author.username}</span>
|
||||
<span className="truncate max-w-[150px]" title={post.author.username}>{post.author.username}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Дата:</span>
|
||||
@@ -257,7 +257,7 @@ const BlogPost: React.FC = () => {
|
||||
{post.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">
|
||||
<span className="font-semibold text-gray-900 truncate max-w-[150px]" title={comment.user ? comment.user.username : (comment.authorName ?? undefined)}>
|
||||
{comment.user ? comment.user.username : comment.authorName}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
|
||||
@@ -769,93 +769,90 @@ const AdminPanel = () => {
|
||||
<p className="font-semibold">{usersError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Пользователь</th>
|
||||
<th className="px-4 py-3 text-left">Email</th>
|
||||
<th className="px-4 py-3 text-left">Баланс</th>
|
||||
<th className="px-4 py-3 text-left">Сервера</th>
|
||||
<th className="px-4 py-3 text-left">Тикеты</th>
|
||||
<th className="px-4 py-3 text-left">Роли</th>
|
||||
<th className="px-4 py-3 text-right">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-gray-700">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<>
|
||||
{/* Mobile: stacked cards */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-500">Пользователи не найдены.</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<div key={user.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||
<div className="text-sm text-gray-500 mt-2">{user.email}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`font-semibold ${user.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>{formatCurrency(user.balance)}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{formatNumber(user._count.buckets ?? 0)} бакетов · {formatNumber(user._count.tickets ?? 0)} тикетов</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button onClick={() => void openUserDetails(user.id)} className="text-blue-600 text-sm">Подробнее</button>
|
||||
<button onClick={() => void handleToggleAdmin(user)} disabled={roleUpdating[user.id]} className="text-purple-600 text-sm">{user.isAdmin ? 'Снять админа' : 'Дать админа'}</button>
|
||||
<button onClick={() => void handleToggleOperator(user)} disabled={roleUpdating[user.id]} className="text-indigo-600 text-sm">{user.operator ? 'Снять оператора' : 'Дать оператора'}</button>
|
||||
<button onClick={() => void handleDeleteUser(user)} disabled={deletingUserId===user.id} className="text-red-600 text-sm">{deletingUserId===user.id ? 'Удаляем...' : 'Удалить'}</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block overflow-hidden rounded-lg border border-gray-200 bg-white shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-gray-500" colSpan={7}>
|
||||
Пользователи не найдены.
|
||||
</td>
|
||||
<th className="px-4 py-3 text-left">Пользователь</th>
|
||||
<th className="px-4 py-3 text-left">Email</th>
|
||||
<th className="px-4 py-3 text-left">Баланс</th>
|
||||
<th className="px-4 py-3 text-left">Сервера</th>
|
||||
<th className="px-4 py-3 text-left">Тикеты</th>
|
||||
<th className="px-4 py-3 text-left">Роли</th>
|
||||
<th className="px-4 py-3 text-right">Действия</th>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const busy = roleUpdating[user.id] || deletingUserId === user.id;
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{user.email}</td>
|
||||
<td
|
||||
className={`px-4 py-3 font-medium ${
|
||||
user.balance >= 0 ? 'text-gray-900' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(user.balance)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.buckets ?? 0)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.tickets ?? 0)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.isAdmin ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
Админ
|
||||
</span>
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.operator ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
Оператор
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2 text-xs font-medium">
|
||||
<button
|
||||
onClick={() => void openUserDetails(user.id)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleToggleAdmin(user)}
|
||||
disabled={busy}
|
||||
className="text-purple-600 hover:text-purple-800 disabled:opacity-50"
|
||||
>
|
||||
{user.isAdmin ? 'Снять админа' : 'Дать админа'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleToggleOperator(user)}
|
||||
disabled={busy}
|
||||
className="text-indigo-600 hover:text-indigo-800 disabled:opacity-50"
|
||||
>
|
||||
{user.operator ? 'Снять оператора' : 'Дать оператора'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleDeleteUser(user)}
|
||||
disabled={busy}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
{deletingUserId === user.id ? 'Удаляем...' : 'Удалить'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-gray-700">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-6 text-center text-gray-500" colSpan={7}>Пользователи не найдены.</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const busy = roleUpdating[user.id] || deletingUserId === user.id;
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{user.username}</div>
|
||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600">{user.email}</td>
|
||||
<td className={`px-4 py-3 font-medium ${user.balance >= 0 ? 'text-gray-900' : 'text-red-600'}`}>{formatCurrency(user.balance)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.buckets ?? 0)}</td>
|
||||
<td className="px-4 py-3">{formatNumber(user._count.tickets ?? 0)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.isAdmin ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-500'}`}>Админ</span>
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${user.operator ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>Оператор</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2 text-xs font-medium">
|
||||
<button onClick={() => void openUserDetails(user.id)} className="text-blue-600 hover:text-blue-800">Подробнее</button>
|
||||
<button onClick={() => void handleToggleAdmin(user)} disabled={busy} className="text-purple-600 hover:text-purple-800 disabled:opacity-50">{user.isAdmin ? 'Снять админа' : 'Дать админа'}</button>
|
||||
<button onClick={() => void handleToggleOperator(user)} disabled={busy} className="text-indigo-600 hover:text-indigo-800 disabled:opacity-50">{user.operator ? 'Снять оператора' : 'Дать оператора'}</button>
|
||||
<button onClick={() => void handleDeleteUser(user)} disabled={busy} className="text-red-600 hover:text-red-800 disabled:opacity-50">{deletingUserId === user.id ? 'Удаляем...' : 'Удалить'}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { API_URL } from '../../config/api';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
||||
@@ -15,6 +16,9 @@ interface Check {
|
||||
}
|
||||
|
||||
const Billing = () => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [amount, setAmount] = useState<number>(0);
|
||||
const [balance, setBalance] = useState<number>(0);
|
||||
const [checks, setChecks] = useState<Check[]>([]);
|
||||
@@ -136,11 +140,11 @@ const Billing = () => {
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'Зачислено';
|
||||
return isEn ? 'Approved' : 'Зачислено';
|
||||
case 'rejected':
|
||||
return 'Отклонено';
|
||||
return isEn ? 'Rejected' : 'Отклонено';
|
||||
default:
|
||||
return 'На проверке';
|
||||
return isEn ? 'Pending' : 'На проверке';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +161,9 @@ const Billing = () => {
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">Пополнение баланса</h2>
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">
|
||||
{isEn ? 'Top Up Balance' : 'Пополнение баланса'}
|
||||
</h2>
|
||||
|
||||
{/* Сообщение */}
|
||||
{message && (
|
||||
@@ -172,7 +178,7 @@ const Billing = () => {
|
||||
|
||||
{/* Текущий баланс */}
|
||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl mb-6">
|
||||
<p className="text-sm text-gray-600 mb-1">Текущий баланс</p>
|
||||
<p className="text-sm text-gray-600 mb-1">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
||||
<p className="text-3xl lg:text-4xl font-extrabold text-ospab-primary">{balance.toFixed(2)} ₽</p>
|
||||
</div>
|
||||
|
||||
@@ -181,7 +187,7 @@ const Billing = () => {
|
||||
{/* Ввод суммы */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="amount" className="block text-gray-700 font-semibold mb-2">
|
||||
Сумма пополнения (₽)
|
||||
{isEn ? 'Top-up amount (₽)' : 'Сумма пополнения (₽)'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -190,13 +196,13 @@ const Billing = () => {
|
||||
onChange={(e) => setAmount(Number(e.target.value))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
min="1"
|
||||
placeholder="Введите сумму"
|
||||
placeholder={isEn ? 'Enter amount' : 'Введите сумму'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Быстрые суммы */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600 mb-2">Быстрый выбор:</p>
|
||||
<p className="text-sm text-gray-600 mb-2">{isEn ? 'Quick select:' : 'Быстрый выбор:'}</p>
|
||||
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{quickAmounts.map((quickAmount) => (
|
||||
<button
|
||||
@@ -219,18 +225,18 @@ const Billing = () => {
|
||||
disabled={amount <= 0}
|
||||
className="w-full px-5 py-3 rounded-xl text-white font-bold transition-colors bg-ospab-primary hover:bg-ospab-accent disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
Перейти к оплате
|
||||
{isEn ? 'Proceed to Payment' : 'Перейти к оплате'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Инструкция */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
||||
<p className="font-bold text-blue-800 mb-2">Инструкция по оплате</p>
|
||||
<p className="font-bold text-blue-800 mb-2">{isEn ? 'Payment Instructions' : 'Инструкция по оплате'}</p>
|
||||
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
|
||||
<li>Переведите <strong>₽{amount}</strong> по СБП или на карту</li>
|
||||
<li>Сохраните чек об оплате</li>
|
||||
<li>Загрузите чек ниже для проверки</li>
|
||||
<li>{isEn ? <>Transfer <strong>₽{amount}</strong> via SBP or to card</> : <>Переведите <strong>₽{amount}</strong> по СБП или на карту</>}</li>
|
||||
<li>{isEn ? 'Save the payment receipt' : 'Сохраните чек об оплате'}</li>
|
||||
<li>{isEn ? 'Upload the receipt below for verification' : 'Загрузите чек ниже для проверки'}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -238,18 +244,18 @@ const Billing = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* QR СБП */}
|
||||
<div className="bg-gray-100 p-4 rounded-xl">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Оплата по СБП</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Pay via SBP' : 'Оплата по СБП'}</h3>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE'} size={200} />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
||||
Отсканируйте QR-код в приложении банка
|
||||
{isEn ? 'Scan QR code in your bank app' : 'Отсканируйте QR-код в приложении банка'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Номер карты */}
|
||||
<div className="bg-gray-100 p-4 rounded-xl">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Номер карты</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Card Number' : 'Номер карты'}</h3>
|
||||
<p className="text-xl font-mono font-bold text-gray-800 break-all mb-3 bg-white p-4 rounded-lg">
|
||||
{cardNumber || '0000 0000 0000 0000'}
|
||||
</p>
|
||||
@@ -257,35 +263,35 @@ const Billing = () => {
|
||||
onClick={handleCopyCard}
|
||||
className="w-full px-4 py-2 rounded-lg text-white font-semibold bg-gray-700 hover:bg-gray-800 transition"
|
||||
>
|
||||
Скопировать номер карты
|
||||
{isEn ? 'Copy card number' : 'Скопировать номер карты'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Загрузка чека */}
|
||||
<div className="bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-6 mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">Загрузка чека</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 mb-3">{isEn ? 'Upload Receipt' : 'Загрузка чека'}</h3>
|
||||
{checkFile ? (
|
||||
<div>
|
||||
<p className="text-gray-700 mb-2">
|
||||
<strong>Выбран файл:</strong> {checkFile.name}
|
||||
<strong>{isEn ? 'Selected file:' : 'Выбран файл:'}</strong> <span className="break-all" title={checkFile.name}>{checkFile.name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Размер: {(checkFile.size / 1024 / 1024).toFixed(2)} МБ
|
||||
{isEn ? 'Size:' : 'Размер:'} {(checkFile.size / 1024 / 1024).toFixed(2)} {isEn ? 'MB' : 'МБ'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCheckFile(null)}
|
||||
className="px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400 transition"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Remove' : 'Удалить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckUpload}
|
||||
disabled={uploadLoading}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition disabled:bg-gray-400"
|
||||
>
|
||||
{uploadLoading ? 'Загрузка...' : 'Отправить чек'}
|
||||
{uploadLoading ? (isEn ? 'Uploading...' : 'Загрузка...') : (isEn ? 'Submit receipt' : 'Отправить чек')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,7 +299,7 @@ const Billing = () => {
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-2">
|
||||
<label className="text-ospab-primary cursor-pointer hover:underline font-semibold">
|
||||
Нажмите, чтобы выбрать файл
|
||||
{isEn ? 'Click to select a file' : 'Нажмите, чтобы выбрать файл'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
@@ -302,7 +308,7 @@ const Billing = () => {
|
||||
/>
|
||||
</label>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">JPG, PNG, PDF (до 10 МБ)</p>
|
||||
<p className="text-sm text-gray-500">{isEn ? 'JPG, PNG, PDF (up to 10 MB)' : 'JPG, PNG, PDF (до 10 МБ)'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -314,14 +320,14 @@ const Billing = () => {
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300 transition"
|
||||
>
|
||||
Изменить сумму
|
||||
{isEn ? 'Change amount' : 'Изменить сумму'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* История чеков */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">История чеков</h3>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">{isEn ? 'Receipt History' : 'История чеков'}</h3>
|
||||
{checks.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{checks.map((check) => (
|
||||
@@ -332,7 +338,7 @@ const Billing = () => {
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">{check.amount} ₽</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{new Date(check.createdAt).toLocaleString('ru-RU')}
|
||||
{new Date(check.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -362,18 +368,18 @@ const Billing = () => {
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Ошибка загрузки чека:', err);
|
||||
showMessage('Не удалось загрузить чек', 'error');
|
||||
showMessage(isEn ? 'Failed to load receipt' : 'Не удалось загрузить чек', 'error');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Чек
|
||||
{isEn ? 'Receipt' : 'Чек'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">История чеков пуста</p>
|
||||
<p className="text-gray-500 text-center py-4">{isEn ? 'No receipts yet' : 'История чеков пуста'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,8 @@ type CartPayload = {
|
||||
plan: CheckoutPlan;
|
||||
price: number;
|
||||
expiresAt: string;
|
||||
originalPrice?: number | null;
|
||||
promoDiscount?: number | null;
|
||||
};
|
||||
|
||||
const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/;
|
||||
@@ -187,14 +189,33 @@ const Checkout: React.FC = () => {
|
||||
|
||||
const handleApplyPromo = useCallback(async () => {
|
||||
if (!cart) return;
|
||||
if (!promoCode.trim()) {
|
||||
setPromoError('Введите промокод');
|
||||
return;
|
||||
}
|
||||
setPromoError(null);
|
||||
try {
|
||||
const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode });
|
||||
const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode: promoCode.trim() });
|
||||
const updated = res.data?.cart;
|
||||
if (updated) setCart(updated as CartPayload);
|
||||
setPromoApplied(true);
|
||||
} catch (err) {
|
||||
setPromoError(err instanceof Error ? err.message : 'Не удалось применить промокод');
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { error?: string } }; message?: string };
|
||||
const raw = axiosErr.response?.data?.error || axiosErr.message || 'Не удалось применить промокод';
|
||||
|
||||
// Friendly localized mapping
|
||||
const msg = String(raw || '').toLowerCase();
|
||||
if (msg.includes('неверный') || msg.includes('invalid')) {
|
||||
setPromoError('Неверный промокод');
|
||||
} else if (msg.includes('уже использован') || msg.includes('already used')) {
|
||||
setPromoError('Этот промокод уже использован');
|
||||
} else if (msg.includes('корзина не найдена') || msg.includes('cart')) {
|
||||
setPromoError('Корзина не найдена или просрочена');
|
||||
} else if (msg.includes('promoCode модель недоступна') || msg.includes('promocode модель')) {
|
||||
setPromoError('Серверная ошибка: PromoCode модель недоступна. Выполните prisma generate на сервере.');
|
||||
} else {
|
||||
setPromoError(raw as string);
|
||||
}
|
||||
}
|
||||
}, [cart, promoCode]);
|
||||
|
||||
@@ -287,14 +308,66 @@ const Checkout: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full md:w-1/3 mt-4 md:mt-0">
|
||||
<label className="block text-sm font-medium text-gray-700">Промокод</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<input value={promoCode} onChange={(e) => setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" />
|
||||
<button onClick={handleApplyPromo} disabled={!isLoggedIn} className={`px-3 py-1 rounded ${isLoggedIn ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>{isLoggedIn ? 'Применить' : 'Войдите, чтобы применить'}</button>
|
||||
{/* Promo Code Section */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FiShoppingCart className="text-ospab-primary text-xl" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Промокод</h3>
|
||||
</div>
|
||||
{promoError && <div className="text-red-500 text-sm mt-1">{promoError}</div>}
|
||||
{promoApplied && !promoError && <div className="text-green-600 text-sm mt-1">Промокод применён</div>}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
if (promoError) setPromoError(null);
|
||||
if (promoApplied) setPromoApplied(false);
|
||||
}}
|
||||
className={`w-full border-2 rounded-xl px-4 py-3 text-lg font-mono tracking-wider uppercase transition-colors focus:outline-none focus:ring-2 focus:ring-ospab-primary/20 ${
|
||||
promoApplied ? 'border-green-500 bg-green-50' :
|
||||
promoError ? 'border-red-300 bg-red-50' :
|
||||
'border-gray-200 focus:border-ospab-primary'
|
||||
}`}
|
||||
|
||||
disabled={promoApplied || !isLoggedIn}
|
||||
maxLength={20}
|
||||
/>
|
||||
{promoApplied && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-green-500">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleApplyPromo}
|
||||
disabled={!isLoggedIn || promoApplied || !promoCode.trim()}
|
||||
className={`px-6 py-3 rounded-xl font-semibold transition-all ${
|
||||
promoApplied
|
||||
? 'bg-green-100 text-green-700 cursor-default'
|
||||
: !isLoggedIn || !promoCode.trim()
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-ospab-primary text-white hover:bg-ospab-accent hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{promoApplied ? '✓ Применён' : !isLoggedIn ? 'Войдите' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
{promoError && (
|
||||
<div className="mt-3 flex items-center gap-2 text-red-600 text-sm bg-red-50 px-4 py-2 rounded-lg">
|
||||
<FiAlertCircle />
|
||||
<span>{promoError}</span>
|
||||
</div>
|
||||
)}
|
||||
{promoApplied && !promoError && (
|
||||
<div className="mt-3 flex items-center gap-2 text-green-600 text-sm bg-green-50 px-4 py-2 rounded-lg">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Промокод успешно применён! Скидка учтена в итоговой сумме.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
@@ -481,12 +554,72 @@ const Checkout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{plan && (
|
||||
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{balanceAfterPayment >= 0
|
||||
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
||||
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
|
||||
</p>
|
||||
<>
|
||||
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{balanceAfterPayment >= 0
|
||||
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
||||
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
|
||||
</p>
|
||||
|
||||
{/* Discount row */}
|
||||
{cart && typeof cart.originalPrice === 'number' && cart.originalPrice > (cart.price ?? 0) && (
|
||||
<div className="flex items-center justify-between gap-3 pt-3">
|
||||
<p className="text-gray-500">Скидка</p>
|
||||
<p className="font-semibold text-gray-900">-{formatCurrency((cart.originalPrice ?? 0) - (cart.price ?? 0))}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Promo: moved here between total and button */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Промокод</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
if (promoError) setPromoError(null);
|
||||
if (promoApplied) setPromoApplied(false);
|
||||
}}
|
||||
className={`flex-1 rounded-xl px-4 py-2 text-sm font-mono tracking-wider uppercase transition-colors focus:outline-none focus:ring-2 focus:ring-ospab-primary/20 ${
|
||||
promoApplied ? 'border-green-500 bg-green-50' :
|
||||
promoError ? 'border-red-300 bg-red-50' :
|
||||
'border-gray-200'
|
||||
}`}
|
||||
placeholder={''}
|
||||
disabled={promoApplied || !isLoggedIn}
|
||||
maxLength={20}
|
||||
/>
|
||||
<button
|
||||
onClick={handleApplyPromo}
|
||||
disabled={!isLoggedIn || promoApplied || !promoCode.trim()}
|
||||
className={`px-4 py-2 rounded-xl font-semibold transition-all ${
|
||||
promoApplied
|
||||
? 'bg-green-100 text-green-700 cursor-default'
|
||||
: !isLoggedIn || !promoCode.trim()
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-ospab-primary text-white hover:bg-ospab-accent hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{promoApplied ? '✓ Применён' : !isLoggedIn ? 'Войдите' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
{promoError && (
|
||||
<div className="mt-2 flex items-center gap-2 text-red-600 text-sm bg-red-50 px-3 py-2 rounded-lg">
|
||||
<FiAlertCircle />
|
||||
<span>{promoError}</span>
|
||||
</div>
|
||||
)}
|
||||
{promoApplied && !promoError && (
|
||||
<div className="mt-2 flex items-center gap-2 text-green-600 text-sm bg-green-50 px-3 py-2 rounded-lg">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Промокод успешно применён! Скидка учтена в итоговой сумме.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { isAxiosError } from 'axios';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import AuthContext from '../../context/authcontext';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
// Импортируем компоненты для вкладок
|
||||
import Summary from './summary';
|
||||
@@ -29,6 +30,8 @@ const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { userData, setUserData, logout, refreshUser, isInitialized } = useContext(AuthContext);
|
||||
const { locale, setLocale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [activeTab, setActiveTab] = useState('summary');
|
||||
|
||||
@@ -99,27 +102,27 @@ const Dashboard = () => {
|
||||
if (!isInitialized || loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<span className="text-gray-500 text-lg">Загрузка...</span>
|
||||
<span className="text-gray-500 text-lg">{isEn ? 'Loading...' : 'Загрузка...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Вкладки для сайдбара
|
||||
const tabs = [
|
||||
{ key: 'summary', label: 'Сводка', to: '/dashboard' },
|
||||
{ key: 'storage', label: 'Хранилище', to: '/dashboard/storage' },
|
||||
{ key: 'tickets', label: 'Тикеты', to: '/dashboard/tickets' },
|
||||
{ key: 'billing', label: 'Баланс', to: '/dashboard/billing' },
|
||||
{ key: 'settings', label: 'Настройки', to: '/dashboard/settings' },
|
||||
{ key: 'notifications', label: 'Уведомления', to: '/dashboard/notifications' },
|
||||
{ key: 'summary', label: isEn ? 'Summary' : 'Сводка', to: '/dashboard' },
|
||||
{ key: 'storage', label: isEn ? 'Storage' : 'Хранилище', to: '/dashboard/storage' },
|
||||
{ key: 'tickets', label: isEn ? 'Tickets' : 'Тикеты', to: '/dashboard/tickets' },
|
||||
{ key: 'billing', label: isEn ? 'Balance' : 'Баланс', to: '/dashboard/billing' },
|
||||
{ key: 'settings', label: isEn ? 'Settings' : 'Настройки', to: '/dashboard/settings' },
|
||||
{ key: 'notifications', label: isEn ? 'Notifications' : 'Уведомления', to: '/dashboard/notifications' },
|
||||
];
|
||||
const adminTabs = [
|
||||
{ key: 'checkverification', label: 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||
{ key: 'checkverification', label: isEn ? 'Check Verification' : 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||
];
|
||||
|
||||
const superAdminTabs = [
|
||||
{ key: 'admin', label: 'Админ-панель', to: '/dashboard/admin' },
|
||||
{ key: 'blogadmin', label: 'Блог', to: '/dashboard/blogadmin' },
|
||||
{ key: 'admin', label: isEn ? 'Admin Panel' : 'Админ-панель', to: '/dashboard/admin' },
|
||||
{ key: 'blogadmin', label: isEn ? 'Blog' : 'Блог', to: '/dashboard/blogadmin' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -147,22 +150,22 @@ const Dashboard = () => {
|
||||
`}>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800 break-words">
|
||||
Привет, {userData?.user?.username || 'Гость'}!
|
||||
{isEn ? 'Hello' : 'Привет'}, {userData?.user?.username || (isEn ? 'Guest' : 'Гость')}!
|
||||
</h2>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{isOperator && (
|
||||
<span className="inline-block px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
|
||||
Оператор
|
||||
{isEn ? 'Operator' : 'Оператор'}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full">
|
||||
Супер Админ
|
||||
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
Баланс: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||
{isEn ? 'Balance' : 'Баланс'}: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 p-6 overflow-y-auto">
|
||||
@@ -183,7 +186,7 @@ const Dashboard = () => {
|
||||
{isOperator && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-4">
|
||||
Админ панель
|
||||
{isEn ? 'Admin Panel' : 'Админ панель'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{adminTabs.map(tab => (
|
||||
@@ -204,7 +207,7 @@ const Dashboard = () => {
|
||||
{isAdmin && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<p className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-3 px-4">
|
||||
Супер Админ
|
||||
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{superAdminTabs.map(tab => (
|
||||
@@ -223,9 +226,32 @@ const Dashboard = () => {
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-6 border-t border-gray-200 text-xs text-gray-500 text-center">
|
||||
{/* Language Switcher */}
|
||||
<div className="p-4 border-t border-gray-200 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setLocale('ru')}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
locale === 'ru'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLocale('en')}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
locale === 'en'
|
||||
? 'bg-ospab-primary text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 pt-2 border-t border-gray-200 text-xs text-gray-500 text-center">
|
||||
<p>© 2025 ospab.host</p>
|
||||
<p className="mt-1">Версия 1.0.0</p>
|
||||
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -241,10 +267,10 @@ const Dashboard = () => {
|
||||
<div className="flex-1 flex flex-col w-full lg:w-auto">
|
||||
<div className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4 pt-16 lg:pt-4">
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-gray-900 capitalize break-words">
|
||||
{tabs.concat(adminTabs).find(t => t.key === activeTab)?.label || 'Панель управления'}
|
||||
{tabs.concat(adminTabs).concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}
|
||||
</h1>
|
||||
<p className="text-xs lg:text-sm text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString('ru-RU', {
|
||||
{new Date().toLocaleDateString(isEn ? 'en-US' : 'ru-RU', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
getNotifications,
|
||||
markAsRead,
|
||||
@@ -16,6 +17,8 @@ const NotificationsPage = () => {
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||
const [pushEnabled, setPushEnabled] = useState(false);
|
||||
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const checkPushPermission = () => {
|
||||
if ('Notification' in window) {
|
||||
@@ -83,7 +86,7 @@ const NotificationsPage = () => {
|
||||
};
|
||||
|
||||
const handleDeleteAllRead = async () => {
|
||||
if (!window.confirm('Удалить все прочитанные уведомления?')) return;
|
||||
if (!window.confirm(isEn ? 'Delete all read notifications?' : 'Удалить все прочитанные уведомления?')) return;
|
||||
|
||||
try {
|
||||
await deleteAllRead();
|
||||
@@ -98,9 +101,9 @@ const NotificationsPage = () => {
|
||||
if (success) {
|
||||
setPushEnabled(true);
|
||||
setPushPermission('granted');
|
||||
alert('Push-уведомления успешно подключены!');
|
||||
alert(isEn ? 'Push notifications enabled!' : 'Push-уведомления успешно подключены!');
|
||||
} else {
|
||||
alert('Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
|
||||
alert(isEn ? 'Failed to enable push notifications. Check your browser permissions.' : 'Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
|
||||
// Обновляем состояние на случай, если пользователь отклонил
|
||||
checkPushPermission();
|
||||
}
|
||||
@@ -108,7 +111,7 @@ const NotificationsPage = () => {
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
return date.toLocaleString(isEn ? 'en-US' : 'ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
@@ -125,11 +128,16 @@ const NotificationsPage = () => {
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const todayLabel = isEn ? 'Today' : 'Сегодня';
|
||||
const yesterdayLabel = isEn ? 'Yesterday' : 'Вчера';
|
||||
const weekLabel = isEn ? 'Last 7 days' : 'За последние 7 дней';
|
||||
const earlierLabel = isEn ? 'Earlier' : 'Ранее';
|
||||
|
||||
const groups: Record<string, Notification[]> = {
|
||||
'Сегодня': [],
|
||||
'Вчера': [],
|
||||
'За последние 7 дней': [],
|
||||
'Ранее': []
|
||||
[todayLabel]: [],
|
||||
[yesterdayLabel]: [],
|
||||
[weekLabel]: [],
|
||||
[earlierLabel]: []
|
||||
};
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
@@ -137,13 +145,13 @@ const NotificationsPage = () => {
|
||||
const notifDay = new Date(notifDate.getFullYear(), notifDate.getMonth(), notifDate.getDate());
|
||||
|
||||
if (notifDay.getTime() === today.getTime()) {
|
||||
groups['Сегодня'].push(notification);
|
||||
groups[todayLabel].push(notification);
|
||||
} else if (notifDay.getTime() === yesterday.getTime()) {
|
||||
groups['Вчера'].push(notification);
|
||||
groups[yesterdayLabel].push(notification);
|
||||
} else if (notifDate >= weekAgo) {
|
||||
groups['За последние 7 дней'].push(notification);
|
||||
groups[weekLabel].push(notification);
|
||||
} else {
|
||||
groups['Ранее'].push(notification);
|
||||
groups[earlierLabel].push(notification);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,7 +164,7 @@ const NotificationsPage = () => {
|
||||
return (
|
||||
<div className="p-4 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Уведомления</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">{isEn ? 'Notifications' : 'Уведомления'}</h1>
|
||||
|
||||
{/* Панель действий */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
@@ -171,7 +179,7 @@ const NotificationsPage = () => {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Все ({notifications.length})
|
||||
{isEn ? 'All' : 'Все'} ({notifications.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('unread')}
|
||||
@@ -181,7 +189,7 @@ const NotificationsPage = () => {
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Непрочитанные ({unreadCount})
|
||||
{isEn ? 'Unread' : 'Непрочитанные'} ({unreadCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +200,7 @@ const NotificationsPage = () => {
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
Прочитать все
|
||||
{isEn ? 'Mark all as read' : 'Прочитать все'}
|
||||
</button>
|
||||
)}
|
||||
{notifications.some((n) => n.isRead) && (
|
||||
@@ -200,7 +208,7 @@ const NotificationsPage = () => {
|
||||
onClick={handleDeleteAllRead}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
|
||||
>
|
||||
Удалить прочитанные
|
||||
{isEn ? 'Delete read' : 'Удалить прочитанные'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,16 +222,16 @@ const NotificationsPage = () => {
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
||||
Подключите Push-уведомления
|
||||
{isEn ? 'Enable Push Notifications' : 'Подключите Push-уведомления'}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mb-3">
|
||||
Получайте мгновенные уведомления на компьютер или телефон при важных событиях
|
||||
{isEn ? 'Get instant notifications on your device for important events' : 'Получайте мгновенные уведомления на компьютер или телефон при важных событиях'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleEnablePush}
|
||||
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Включить уведомления
|
||||
{isEn ? 'Enable notifications' : 'Включить уведомления'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,11 +248,13 @@ const NotificationsPage = () => {
|
||||
Push-уведомления заблокированы
|
||||
</h3>
|
||||
<p className="text-sm text-red-700">
|
||||
Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера.
|
||||
{isEn ? 'You have blocked notifications for this site. To enable them, allow notifications in your browser settings.' : 'Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера.'}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-2">
|
||||
Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить<br/>
|
||||
Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки
|
||||
{isEn
|
||||
? <>Chrome/Edge: Click the lock icon to the left of the address bar → Notifications → Allow<br/>Firefox: Settings → Privacy & Security → Permissions → Notifications → Settings</>
|
||||
: <>Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить<br/>Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,9 +271,11 @@ const NotificationsPage = () => {
|
||||
<svg className="mx-auto h-16 w-16 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет уведомлений</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">{isEn ? 'No notifications' : 'Нет уведомлений'}</h3>
|
||||
<p className="text-gray-600">
|
||||
{filter === 'unread' ? 'Все уведомления прочитаны' : 'У вас пока нет уведомлений'}
|
||||
{filter === 'unread'
|
||||
? (isEn ? 'All notifications are read' : 'Все уведомления прочитаны')
|
||||
: (isEn ? 'You have no notifications yet' : 'У вас пока нет уведомлений')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, createContext, useContext } from 'react';
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../../config/api';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
@@ -30,10 +31,16 @@ import {
|
||||
|
||||
type TabType = 'profile' | 'security' | 'notifications' | 'api' | 'ssh' | 'delete';
|
||||
|
||||
// Context for sharing isEn across settings tabs
|
||||
const SettingsLangContext = createContext<boolean>(false);
|
||||
const useSettingsLang = () => useContext(SettingsLangContext);
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
@@ -60,21 +67,22 @@ const SettingsPage = () => {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as TabType, label: 'Профиль' },
|
||||
{ id: 'security' as TabType, label: 'Безопасность' },
|
||||
{ id: 'notifications' as TabType, label: 'Уведомления' },
|
||||
{ id: 'api' as TabType, label: 'API ключи' },
|
||||
{ id: 'ssh' as TabType, label: 'SSH ключи' },
|
||||
{ id: 'delete' as TabType, label: 'Удаление' },
|
||||
{ id: 'profile' as TabType, label: isEn ? 'Profile' : 'Профиль' },
|
||||
{ id: 'security' as TabType, label: isEn ? 'Security' : 'Безопасность' },
|
||||
{ id: 'notifications' as TabType, label: isEn ? 'Notifications' : 'Уведомления' },
|
||||
{ id: 'api' as TabType, label: isEn ? 'API Keys' : 'API ключи' },
|
||||
{ id: 'ssh' as TabType, label: isEn ? 'SSH Keys' : 'SSH ключи' },
|
||||
{ id: 'delete' as TabType, label: isEn ? 'Delete' : 'Удаление' },
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsLangContext.Provider value={isEn}>
|
||||
<div className="p-4 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Заголовок */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Настройки аккаунта</h1>
|
||||
<p className="text-gray-600 mt-2">Управление профилем, безопасностью и интеграциями</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Account Settings' : 'Настройки аккаунта'}</h1>
|
||||
<p className="text-gray-600 mt-2">{isEn ? 'Manage profile, security and integrations' : 'Управление профилем, безопасностью и интеграциями'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
@@ -113,11 +121,13 @@ const SettingsPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsLangContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============ ПРОФИЛЬ ============
|
||||
const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpdate: () => void }) => {
|
||||
const isEn = useSettingsLang();
|
||||
const [username, setUsername] = useState(profile?.username || '');
|
||||
const [email, setEmail] = useState(profile?.email || '');
|
||||
const [phoneNumber, setPhoneNumber] = useState(profile?.profile?.phoneNumber || '');
|
||||
@@ -145,29 +155,29 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
try {
|
||||
setSaving(true);
|
||||
await uploadAvatar(avatarFile);
|
||||
alert('Аватар загружен!');
|
||||
alert(isEn ? 'Avatar uploaded!' : 'Аватар загружен!');
|
||||
onUpdate();
|
||||
setAvatarFile(null);
|
||||
setAvatarPreview(null);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки аватара:', error);
|
||||
alert('Ошибка загрузки аватара');
|
||||
alert(isEn ? 'Error uploading avatar' : 'Ошибка загрузки аватара');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAvatar = async () => {
|
||||
if (!confirm('Удалить аватар?')) return;
|
||||
if (!confirm(isEn ? 'Delete avatar?' : 'Удалить аватар?')) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await deleteAvatar();
|
||||
alert('Аватар удалён');
|
||||
alert(isEn ? 'Avatar deleted' : 'Аватар удалён');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления аватара:', error);
|
||||
alert('Ошибка удаления аватара');
|
||||
alert(isEn ? 'Error deleting avatar' : 'Ошибка удаления аватара');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -177,11 +187,11 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateProfile({ username, email, phoneNumber, timezone, language });
|
||||
alert('Профиль обновлён!');
|
||||
alert(isEn ? 'Profile updated!' : 'Профиль обновлён!');
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления профиля:', error);
|
||||
alert('Ошибка обновления профиля');
|
||||
alert(isEn ? 'Error updating profile' : 'Ошибка обновления профиля');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -190,13 +200,13 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Профиль</h2>
|
||||
<p className="text-gray-600">Обновите информацию о своём профиле</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Profile' : 'Профиль'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Update your profile information' : 'Обновите информацию о своём профиле'}</p>
|
||||
</div>
|
||||
|
||||
{/* Аватар */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Аватар</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Avatar' : 'Аватар'}</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||
{avatarPreview || profile?.profile?.avatarUrl ? (
|
||||
@@ -213,7 +223,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="px-4 py-2 bg-ospab-primary text-white rounded-lg cursor-pointer hover:bg-ospab-accent transition">
|
||||
Выбрать файл
|
||||
{isEn ? 'Choose file' : 'Выбрать файл'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -227,7 +237,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Загрузить
|
||||
{isEn ? 'Upload' : 'Загрузить'}
|
||||
</button>
|
||||
)}
|
||||
{profile?.profile?.avatarUrl && (
|
||||
@@ -236,7 +246,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Delete' : 'Удалить'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -245,10 +255,10 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
|
||||
{/* Основная информация */}
|
||||
<div className="border-t border-gray-200 pt-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Основная информация</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Basic Information' : 'Основная информация'}</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Имя пользователя</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Username' : 'Имя пользователя'}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
@@ -268,7 +278,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Телефон (опционально)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Phone (optional)' : 'Телефон (опционально)'}</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
@@ -280,7 +290,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Часовой пояс</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Timezone' : 'Часовой пояс'}</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
@@ -294,7 +304,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Язык</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Language' : 'Язык'}</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
@@ -311,7 +321,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
disabled={saving}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
{saving ? (isEn ? 'Saving...' : 'Сохранение...') : (isEn ? 'Save changes' : 'Сохранить изменения')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,13 +330,14 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
||||
|
||||
// ============ БЕЗОПАСНОСТЬ ============
|
||||
const SecurityTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [view, setView] = useState<'password' | 'sessions'>('password');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Безопасность</h2>
|
||||
<p className="text-gray-600">Управление паролем и активными сеансами</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Security' : 'Безопасность'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Manage password and active sessions' : 'Управление паролем и активными сеансами'}</p>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
@@ -339,7 +350,7 @@ const SecurityTab = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Смена пароля
|
||||
{isEn ? 'Change Password' : 'Смена пароля'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('sessions')}
|
||||
@@ -349,7 +360,7 @@ const SecurityTab = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Активные сеансы
|
||||
{isEn ? 'Active Sessions' : 'Активные сеансы'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -360,6 +371,7 @@ const SecurityTab = () => {
|
||||
};
|
||||
|
||||
const PasswordChange = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -367,11 +379,11 @@ const PasswordChange = () => {
|
||||
|
||||
const getPasswordStrength = (password: string) => {
|
||||
if (password.length === 0) return { strength: 0, label: '' };
|
||||
if (password.length < 6) return { strength: 1, label: 'Слабый', color: 'bg-red-500' };
|
||||
if (password.length < 10) return { strength: 2, label: 'Средний', color: 'bg-yellow-500' };
|
||||
if (password.length < 6) return { strength: 1, label: isEn ? 'Weak' : 'Слабый', color: 'bg-red-500' };
|
||||
if (password.length < 10) return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||||
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password))
|
||||
return { strength: 2, label: 'Средний', color: 'bg-yellow-500' };
|
||||
return { strength: 3, label: 'Сильный', color: 'bg-green-500' };
|
||||
return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||||
return { strength: 3, label: isEn ? 'Strong' : 'Сильный', color: 'bg-green-500' };
|
||||
};
|
||||
|
||||
const strength = getPasswordStrength(newPassword);
|
||||
@@ -380,20 +392,20 @@ const PasswordChange = () => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('Пароли не совпадают');
|
||||
alert(isEn ? 'Passwords do not match' : 'Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await changePassword({ currentPassword, newPassword });
|
||||
alert('Пароль успешно изменён!');
|
||||
alert(isEn ? 'Password changed successfully!' : 'Пароль успешно изменён!');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (error) {
|
||||
console.error('Ошибка смены пароля:', error);
|
||||
alert('Ошибка смены пароля. Проверьте текущий пароль.');
|
||||
alert(isEn ? 'Password change error. Check current password.' : 'Ошибка смены пароля. Проверьте текущий пароль.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -402,7 +414,7 @@ const PasswordChange = () => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Текущий пароль</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Current password' : 'Текущий пароль'}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
@@ -413,7 +425,7 @@ const PasswordChange = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Новый пароль</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'New password' : 'Новый пароль'}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
@@ -434,13 +446,13 @@ const PasswordChange = () => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">Сила пароля: {strength.label}</p>
|
||||
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Password strength:' : 'Сила пароля:'} {strength.label}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Подтвердите новый пароль</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Confirm new password' : 'Подтвердите новый пароль'}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
@@ -455,13 +467,14 @@ const PasswordChange = () => {
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? 'Изменение...' : 'Изменить пароль'}
|
||||
{loading ? (isEn ? 'Changing...' : 'Изменение...') : (isEn ? 'Change password' : 'Изменить пароль')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveSessions = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loginHistory, setLoginHistory] = useState<LoginHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -494,29 +507,29 @@ const ActiveSessions = () => {
|
||||
};
|
||||
|
||||
const handleTerminate = async (id: number) => {
|
||||
if (!confirm('Вы уверены, что хотите завершить эту сессию?')) return;
|
||||
if (!confirm(isEn ? 'Are you sure you want to terminate this session?' : 'Вы уверены, что хотите завершить эту сессию?')) return;
|
||||
|
||||
try {
|
||||
await terminateSession(id);
|
||||
alert('Сеанс завершён');
|
||||
alert(isEn ? 'Session terminated' : 'Сеанс завершён');
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Ошибка завершения сеанса:', error);
|
||||
alert('Не удалось завершить сессию');
|
||||
alert(isEn ? 'Failed to terminate session' : 'Не удалось завершить сессию');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateAllOthers = async () => {
|
||||
if (!confirm('Вы уверены, что хотите завершить все остальные сессии?')) return;
|
||||
if (!confirm(isEn ? 'Are you sure you want to terminate all other sessions?' : 'Вы уверены, что хотите завершить все остальные сессии?')) return;
|
||||
|
||||
try {
|
||||
// Используем API для завершения всех остальных сессий
|
||||
await apiClient.delete('/api/sessions/others/all');
|
||||
alert('Все остальные сессии завершены');
|
||||
alert(isEn ? 'All other sessions terminated' : 'Все остальные сессии завершены');
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
console.error('Ошибка завершения сессий:', error);
|
||||
alert('Не удалось завершить сессии');
|
||||
alert(isEn ? 'Failed to terminate sessions' : 'Не удалось завершить сессии');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -535,11 +548,11 @@ const ActiveSessions = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -559,7 +572,7 @@ const ActiveSessions = () => {
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>🚫</span>
|
||||
Завершить все остальные сессии
|
||||
{isEn ? 'Terminate all other sessions' : 'Завершить все остальные сессии'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -567,7 +580,7 @@ const ActiveSessions = () => {
|
||||
{/* Сессии в виде карточек */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-gray-600 text-center py-8 col-span-2">Нет активных сеансов</p>
|
||||
<p className="text-gray-600 text-center py-8 col-span-2">{isEn ? 'No active sessions' : 'Нет активных сеансов'}</p>
|
||||
) : (
|
||||
sessions.map((session) => {
|
||||
const isCurrent = session.isCurrent || session.device?.includes('Current');
|
||||
@@ -583,7 +596,7 @@ const ActiveSessions = () => {
|
||||
{isCurrent && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
✓ Текущая сессия
|
||||
✓ {isEn ? 'Current Session' : 'Текущая сессия'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -593,12 +606,12 @@ const ActiveSessions = () => {
|
||||
<div className="text-4xl">{getDeviceIcon(session.device || 'desktop')}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{session.browser || 'Неизвестный браузер'} · {session.device || 'Desktop'}
|
||||
{session.browser || (isEn ? 'Unknown browser' : 'Неизвестный браузер')} · {session.device || 'Desktop'}
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p className="flex items-center gap-2">
|
||||
<span>🌐</span>
|
||||
<span>{session.ipAddress || 'Неизвестно'}</span>
|
||||
<span>{session.ipAddress || (isEn ? 'Unknown' : 'Неизвестно')}</span>
|
||||
</p>
|
||||
{session.location && (
|
||||
<p className="flex items-center gap-2">
|
||||
@@ -608,11 +621,11 @@ const ActiveSessions = () => {
|
||||
)}
|
||||
<p className="flex items-center gap-2">
|
||||
<span>⏱️</span>
|
||||
<span>Активность: {formatRelativeTime(session.lastActivity)}</span>
|
||||
<span>{isEn ? 'Activity' : 'Активность'}: {formatRelativeTime(session.lastActivity)}</span>
|
||||
</p>
|
||||
<p className="flex items-center gap-2 text-gray-500">
|
||||
<span>🔐</span>
|
||||
<span>Вход: {new Date(session.createdAt || session.lastActivity).toLocaleString('ru-RU')}</span>
|
||||
<span>{isEn ? 'Login' : 'Вход'}: {new Date(session.createdAt || session.lastActivity).toLocaleString(isEn ? 'en-US' : 'ru-RU')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -625,7 +638,7 @@ const ActiveSessions = () => {
|
||||
onClick={() => handleTerminate(session.id)}
|
||||
className="w-full bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Завершить сессию
|
||||
{isEn ? 'Terminate Session' : 'Завершить сессию'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -644,8 +657,8 @@ const ActiveSessions = () => {
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">История входов</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">Последние 20 попыток входа в аккаунт</p>
|
||||
<h2 className="text-xl font-bold text-gray-900">{isEn ? 'Login History' : 'История входов'}</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Last 20 login attempts' : 'Последние 20 попыток входа в аккаунт'}</p>
|
||||
</div>
|
||||
<span className="text-2xl">{showHistory ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
@@ -657,16 +670,16 @@ const ActiveSessions = () => {
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Статус
|
||||
{isEn ? 'Status' : 'Статус'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP адрес
|
||||
{isEn ? 'IP Address' : 'IP адрес'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Устройство
|
||||
{isEn ? 'Device' : 'Устройство'}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Дата и время
|
||||
{isEn ? 'Date and Time' : 'Дата и время'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -681,17 +694,17 @@ const ActiveSessions = () => {
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{entry.success ? '✓ Успешно' : '✗ Ошибка'}
|
||||
{entry.success ? (isEn ? '✓ Success' : '✓ Успешно') : (isEn ? '✗ Error' : '✗ Ошибка')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{entry.ipAddress}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || 'Неизвестно'}
|
||||
{entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || (isEn ? 'Unknown' : 'Неизвестно')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{new Date(entry.createdAt).toLocaleString('ru-RU')}
|
||||
{new Date(entry.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -703,12 +716,12 @@ const ActiveSessions = () => {
|
||||
|
||||
{/* Советы по безопасности */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">💡 Советы по безопасности</h3>
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-3">💡 {isEn ? 'Security Tips' : 'Советы по безопасности'}</h3>
|
||||
<ul className="space-y-2 text-sm text-blue-800">
|
||||
<li>• Регулярно проверяйте список активных сессий</li>
|
||||
<li>• Завершайте сессии на устройствах, которыми больше не пользуетесь</li>
|
||||
<li>• Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль</li>
|
||||
<li>• Используйте надёжные пароли и двухфакторную аутентификацию</li>
|
||||
<li>• {isEn ? 'Regularly check the list of active sessions' : 'Регулярно проверяйте список активных сессий'}</li>
|
||||
<li>• {isEn ? 'Terminate sessions on devices you no longer use' : 'Завершайте сессии на устройствах, которыми больше не пользуетесь'}</li>
|
||||
<li>• {isEn ? 'If you see suspicious activity, immediately terminate all sessions and change password' : 'Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль'}</li>
|
||||
<li>• {isEn ? 'Use strong passwords and two-factor authentication' : 'Используйте надёжные пароли и двухфакторную аутентификацию'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -717,6 +730,7 @@ const ActiveSessions = () => {
|
||||
|
||||
// ============ УВЕДОМЛЕНИЯ ============
|
||||
const NotificationsTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -748,7 +762,7 @@ const NotificationsTab = () => {
|
||||
await updateNotificationSettings({ [field]: value });
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления настроек:', error);
|
||||
alert('Ошибка сохранения настроек');
|
||||
alert(isEn ? 'Error saving settings' : 'Ошибка сохранения настроек');
|
||||
loadSettings(); // Revert
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -760,7 +774,7 @@ const NotificationsTab = () => {
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return <div className="text-center py-8 text-gray-600">Ошибка загрузки настроек</div>;
|
||||
return <div className="text-center py-8 text-gray-600">{isEn ? 'Error loading settings' : 'Ошибка загрузки настроек'}</div>;
|
||||
}
|
||||
|
||||
const emailSettings = [
|
||||
@@ -779,13 +793,13 @@ const NotificationsTab = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Уведомления</h2>
|
||||
<p className="text-gray-600">Настройте способы получения уведомлений</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Notifications' : 'Уведомления'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Configure notification methods' : 'Настройте способы получения уведомлений'}</p>
|
||||
</div>
|
||||
|
||||
{/* Email уведомления */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Email уведомления</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Email Notifications' : 'Email уведомления'}</h3>
|
||||
<div className="space-y-3">
|
||||
{emailSettings.map((setting) => (
|
||||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||||
@@ -804,7 +818,7 @@ const NotificationsTab = () => {
|
||||
|
||||
{/* Push уведомления */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Push уведомления</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Push Notifications' : 'Push уведомления'}</h3>
|
||||
<div className="space-y-3">
|
||||
{pushSettings.map((setting) => (
|
||||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||||
@@ -824,7 +838,7 @@ const NotificationsTab = () => {
|
||||
{saving && (
|
||||
<div className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-ospab-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
Сохранение...
|
||||
{isEn ? 'Saving...' : 'Сохранение...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -833,6 +847,7 @@ const NotificationsTab = () => {
|
||||
|
||||
// ============ API КЛЮЧИ ============
|
||||
const APIKeysTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -861,20 +876,20 @@ const APIKeysTab = () => {
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания ключа:', error);
|
||||
alert('Ошибка создания ключа');
|
||||
alert(isEn ? 'Error creating key' : 'Ошибка создания ключа');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Удалить этот API ключ? Приложения, использующие его, перестанут работать.')) return;
|
||||
if (!confirm(isEn ? 'Delete this API key? Applications using it will stop working.' : 'Удалить этот API ключ? Приложения, использующие его, перестанут работать.')) return;
|
||||
|
||||
try {
|
||||
await deleteAPIKey(id);
|
||||
alert('Ключ удалён');
|
||||
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления ключа:', error);
|
||||
alert('Ошибка удаления ключа');
|
||||
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -886,29 +901,29 @@ const APIKeysTab = () => {
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">API ключи</h2>
|
||||
<p className="text-gray-600">Управление ключами для интеграций</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'API Keys' : 'API ключи'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Manage integration keys' : 'Управление ключами для интеграций'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||
>
|
||||
Создать ключ
|
||||
{isEn ? 'Create Key' : 'Создать ключ'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-gray-600 text-center py-8">Нет созданных ключей</p>
|
||||
<p className="text-gray-600 text-center py-8">{isEn ? 'No keys created' : 'Нет созданных ключей'}</p>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Название</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Префикс</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Создан</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">Последнее использование</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-700">Действия</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Name' : 'Название'}</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Prefix' : 'Префикс'}</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Created' : 'Создан'}</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">{isEn ? 'Last Used' : 'Последнее использование'}</th>
|
||||
<th className="text-right py-3 px-4 font-medium text-gray-700">{isEn ? 'Actions' : 'Действия'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -917,17 +932,17 @@ const APIKeysTab = () => {
|
||||
<td className="py-3 px-4">{key.name}</td>
|
||||
<td className="py-3 px-4 font-mono text-sm">{key.prefix}...</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{new Date(key.createdAt).toLocaleDateString('ru-RU')}
|
||||
{new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{key.lastUsed ? new Date(key.lastUsed).toLocaleDateString('ru-RU') : 'Никогда'}
|
||||
{key.lastUsed ? new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU') : (isEn ? 'Never' : 'Никогда')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="text-red-600 hover:text-red-700 text-sm"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Delete' : 'Удалить'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -971,16 +986,16 @@ const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreat
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-bold mb-4">Создать API ключ</h3>
|
||||
<h3 className="text-xl font-bold mb-4">Create API Key</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Название</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Мой проект"
|
||||
placeholder="My project"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
@@ -990,14 +1005,14 @@ const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreat
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Создание...' : 'Создать'}
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1018,10 +1033,10 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<h3 className="text-xl font-bold mb-4 text-green-600">Ключ успешно создан!</h3>
|
||||
<h3 className="text-xl font-bold mb-4 text-green-600">Key created successfully!</h3>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
Сохраните этот ключ сейчас! Он больше не будет показан.
|
||||
Save this key now! It will not be shown again.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-lg p-4 mb-4 font-mono text-sm break-all">
|
||||
@@ -1032,13 +1047,13 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
||||
onClick={handleCopy}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent"
|
||||
>
|
||||
{copied ? 'Скопировано!' : 'Копировать'}
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Закрыть
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1048,6 +1063,7 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
||||
|
||||
// ============ SSH КЛЮЧИ ============
|
||||
const SSHKeysTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -1071,25 +1087,25 @@ const SSHKeysTab = () => {
|
||||
const handleAdd = async (name: string, publicKey: string) => {
|
||||
try {
|
||||
await addSSHKey({ name, publicKey });
|
||||
alert('SSH ключ добавлен');
|
||||
alert(isEn ? 'SSH key added' : 'SSH ключ добавлен');
|
||||
loadKeys();
|
||||
setShowModal(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка добавления ключа:', error);
|
||||
alert('Ошибка добавления ключа. Проверьте формат.');
|
||||
alert(isEn ? 'Error adding key. Check the format.' : 'Ошибка добавления ключа. Проверьте формат.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Удалить этот SSH ключ?')) return;
|
||||
if (!confirm(isEn ? 'Delete this SSH key?' : 'Удалить этот SSH ключ?')) return;
|
||||
|
||||
try {
|
||||
await deleteSSHKey(id);
|
||||
alert('Ключ удалён');
|
||||
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления ключа:', error);
|
||||
alert('Ошибка удаления ключа');
|
||||
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1101,19 +1117,19 @@ const SSHKeysTab = () => {
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">SSH ключи</h2>
|
||||
<p className="text-gray-600">Управление SSH ключами для доступа к серверам</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'SSH Keys' : 'SSH ключи'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Manage SSH keys for server access' : 'Управление SSH ключами для доступа к серверам'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||
>
|
||||
Добавить ключ
|
||||
{isEn ? 'Add Key' : 'Добавить ключ'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-gray-600 text-center py-8">Нет добавленных ключей</p>
|
||||
<p className="text-gray-600 text-center py-8">{isEn ? 'No keys added' : 'Нет добавленных ключей'}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map((key) => (
|
||||
@@ -1124,15 +1140,15 @@ const SSHKeysTab = () => {
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="text-red-600 hover:text-red-700 text-sm"
|
||||
>
|
||||
Удалить
|
||||
{isEn ? 'Delete' : 'Удалить'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
Отпечаток: <span className="font-mono">{key.fingerprint}</span>
|
||||
{isEn ? 'Fingerprint' : 'Отпечаток'}: <span className="font-mono">{key.fingerprint}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Добавлен: {new Date(key.createdAt).toLocaleDateString('ru-RU')}
|
||||
{key.lastUsed && ` • Использован: ${new Date(key.lastUsed).toLocaleDateString('ru-RU')}`}
|
||||
{isEn ? 'Added' : 'Добавлен'}: {new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||||
{key.lastUsed && ` • ${isEn ? 'Used' : 'Использован'}: ${new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -1164,21 +1180,21 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<h3 className="text-xl font-bold mb-4">Добавить SSH ключ</h3>
|
||||
<h3 className="text-xl font-bold mb-4">Add SSH Key</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Название</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Мой ноутбук"
|
||||
placeholder="My laptop"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Публичный ключ</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Public Key</label>
|
||||
<textarea
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
@@ -1188,7 +1204,7 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg font-mono text-sm focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Скопируйте содержимое файла ~/.ssh/id_rsa.pub или ~/.ssh/id_ed25519.pub
|
||||
Copy the contents of ~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pub
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -1197,14 +1213,14 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Добавление...' : 'Добавить'}
|
||||
{loading ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1215,6 +1231,7 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
||||
|
||||
// ============ УДАЛЕНИЕ АККАУНТА ============
|
||||
const DeleteAccountTab = () => {
|
||||
const isEn = useSettingsLang();
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
@@ -1231,44 +1248,44 @@ const DeleteAccountTab = () => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Ошибка экспорта данных:', error);
|
||||
alert('Ошибка экспорта данных');
|
||||
alert(isEn ? 'Error exporting data' : 'Ошибка экспорта данных');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Удаление аккаунта</h2>
|
||||
<p className="text-gray-600">Экспорт данных и безвозвратное удаление аккаунта</p>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Delete Account' : 'Удаление аккаунта'}</h2>
|
||||
<p className="text-gray-600">{isEn ? 'Export data and permanently delete account' : 'Экспорт данных и безвозвратное удаление аккаунта'}</p>
|
||||
</div>
|
||||
|
||||
{/* Экспорт данных */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Экспорт данных</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">{isEn ? 'Export Data' : 'Экспорт данных'}</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Скачайте копию всех ваших данных включая профиль, серверы, тикеты и транзакции в формате JSON.
|
||||
{isEn ? 'Download a copy of all your data including profile, servers, tickets and transactions in JSON format.' : 'Скачайте копию всех ваших данных включая профиль, серверы, тикеты и транзакции в формате JSON.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent transition"
|
||||
>
|
||||
Скачать мои данные
|
||||
{isEn ? 'Download My Data' : 'Скачать мои данные'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Удаление аккаунта */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-4">Опасная зона</h3>
|
||||
<h3 className="text-lg font-semibold text-red-900 mb-4">{isEn ? 'Danger Zone' : 'Опасная зона'}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-red-900 font-medium">Это действие необратимо</p>
|
||||
<p className="text-red-900 font-medium">{isEn ? 'This action is irreversible' : 'Это действие необратимо'}</p>
|
||||
<p className="text-red-700 text-sm mt-1">
|
||||
Все ваши серверы будут остановлены и удалены. История платежей, тикеты и другие данные будут безвозвратно удалены.
|
||||
{isEn ? 'All your servers will be stopped and deleted. Payment history, tickets and other data will be permanently deleted.' : 'Все ваши серверы будут остановлены и удалены. История платежей, тикеты и другие данные будут безвозвратно удалены.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1276,7 +1293,7 @@ const DeleteAccountTab = () => {
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition"
|
||||
>
|
||||
Удалить мой аккаунт
|
||||
{isEn ? 'Delete My Account' : 'Удалить мой аккаунт'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useToast } from '../../hooks/useToast';
|
||||
import { getFiles, deleteFilesByBucket } from '../../utils/uploadDB';
|
||||
import type { StorageAccessKey, StorageBucket, StorageObject } from './types';
|
||||
import { formatBytes, formatCurrency, formatDate, getPlanTone, getStatusBadge, getUsagePercent } from './storage-utils';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface ObjectsResponse {
|
||||
objects: StorageObject[];
|
||||
@@ -61,7 +62,7 @@ interface UploadProgress {
|
||||
|
||||
const TEN_GIB = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
const TAB_ITEMS = [
|
||||
const TAB_ITEMS_RU = [
|
||||
{
|
||||
key: 'summary',
|
||||
label: 'Сводка',
|
||||
@@ -82,7 +83,28 @@ const TAB_ITEMS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TAB_ITEMS)[number]['key'];
|
||||
const TAB_ITEMS_EN = [
|
||||
{
|
||||
key: 'summary',
|
||||
label: 'Summary',
|
||||
icon: FiBarChart2,
|
||||
description: 'Statistics, quotas and current bucket state.',
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: 'Files',
|
||||
icon: FiFolder,
|
||||
description: 'Upload, download and manage objects.',
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: 'Settings',
|
||||
icon: FiSettings,
|
||||
description: 'Access rights, versioning and API keys.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TAB_ITEMS_RU)[number]['key'];
|
||||
|
||||
type LoadObjectsOptions = {
|
||||
reset?: boolean;
|
||||
@@ -175,7 +197,7 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
</span>
|
||||
<span className="flex-1 font-medium text-gray-800">
|
||||
{node.name}
|
||||
<span className="ml-2 text-xs text-gray-400 font-normal">({fileCount} файл.)</span>
|
||||
<span className="ml-2 text-xs text-gray-400 font-normal">({fileCount})</span>
|
||||
</span>
|
||||
<span className="w-24 text-right text-xs text-gray-500">{formatBytes(folderSize)}</span>
|
||||
<span className="w-40 text-right text-xs text-gray-400">—</span>
|
||||
@@ -226,7 +248,7 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
<span className="text-gray-400">
|
||||
<FiFile />
|
||||
</span>
|
||||
<span className="flex-1 font-mono text-xs text-gray-700 break-all">{node.name}</span>
|
||||
<span className="flex-1 font-mono text-sm text-gray-700 break-words" title={node.name}>{node.name}</span>
|
||||
<span className="w-24 text-right text-xs text-gray-600">{formatBytes(node.size)}</span>
|
||||
<span className="w-40 text-right text-xs text-gray-500">
|
||||
{node.lastModified ? formatDate(node.lastModified, true) : '—'}
|
||||
@@ -242,7 +264,6 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
||||
className="inline-flex items-center gap-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<FiDownload />
|
||||
Скачать
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
@@ -257,6 +278,9 @@ const StorageBucketPage: React.FC = () => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const TAB_ITEMS = isEn ? TAB_ITEMS_EN : TAB_ITEMS_RU;
|
||||
|
||||
const objectPrefixRef = useRef('');
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -378,7 +402,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
const fetchBucket = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
if (!bucketIdValid) {
|
||||
setBucket(null);
|
||||
setBucketError('Некорректный идентификатор бакета');
|
||||
setBucketError(isEn ? 'Invalid bucket ID' : 'Некорректный идентификатор бакета');
|
||||
setBucketLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +418,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setBucket(data.bucket);
|
||||
setBucketError(null);
|
||||
} catch (error) {
|
||||
let message = 'Не удалось загрузить бакет';
|
||||
let message = isEn ? 'Failed to load bucket' : 'Не удалось загрузить бакет';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -444,7 +468,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setObjectsCursor(data.nextCursor ?? null);
|
||||
} catch (error) {
|
||||
console.error('[StorageBucket] Не удалось получить список объектов', error);
|
||||
addToast('Не удалось загрузить список объектов', 'error');
|
||||
addToast(isEn ? 'Failed to load objects list' : 'Не удалось загрузить список объектов', 'error');
|
||||
} finally {
|
||||
setObjectsLoading(false);
|
||||
setObjectsLoadingMore(false);
|
||||
@@ -462,7 +486,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setAccessKeys(data.keys);
|
||||
} catch (error) {
|
||||
console.error('[StorageBucket] Не удалось получить ключи доступа', error);
|
||||
addToast('Не удалось загрузить ключи доступа', 'error');
|
||||
addToast(isEn ? 'Failed to load access keys' : 'Не удалось загрузить ключи доступа', 'error');
|
||||
} finally {
|
||||
setAccessKeysLoading(false);
|
||||
}
|
||||
@@ -481,7 +505,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
}
|
||||
dispatchBucketsRefresh();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось обновить настройки бакета';
|
||||
let message = isEn ? 'Failed to update bucket settings' : 'Не удалось обновить настройки бакета';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -496,7 +520,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = !bucket.public;
|
||||
updateBucketSettings({ public: next }, next ? 'Публичный доступ включён' : 'Публичный доступ отключён');
|
||||
updateBucketSettings({ public: next }, next ? (isEn ? 'Public access enabled' : 'Публичный доступ включён') : (isEn ? 'Public access disabled' : 'Публичный доступ отключён'));
|
||||
}, [bucket, updateBucketSettings]);
|
||||
|
||||
const toggleVersioning = useCallback(() => {
|
||||
@@ -504,7 +528,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = !bucket.versioning;
|
||||
updateBucketSettings({ versioning: next }, next ? 'Версионирование включено' : 'Версионирование отключено');
|
||||
updateBucketSettings({ versioning: next }, next ? (isEn ? 'Versioning enabled' : 'Версионирование включено') : (isEn ? 'Versioning disabled' : 'Версионирование отключено'));
|
||||
}, [bucket, updateBucketSettings]);
|
||||
|
||||
const toggleAutoRenew = useCallback(() => {
|
||||
@@ -512,7 +536,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
const next = !bucket.autoRenew;
|
||||
updateBucketSettings({ autoRenew: next }, next ? 'Автопродление включено' : 'Автопродление отключено');
|
||||
updateBucketSettings({ autoRenew: next }, next ? (isEn ? 'Auto-renewal enabled' : 'Автопродление включено') : (isEn ? 'Auto-renewal disabled' : 'Автопродление отключено'));
|
||||
}, [bucket, updateBucketSettings]);
|
||||
|
||||
const handleRefreshBucket = useCallback(() => {
|
||||
@@ -564,7 +588,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
|
||||
const handleDownloadObject = useCallback(async (object: StorageObject) => {
|
||||
if (object.size >= TEN_GIB) {
|
||||
const confirmed = window.confirm('Файл весит больше 10 ГБ. Скачивание может занять продолжительное время. Продолжить?');
|
||||
const confirmed = window.confirm(isEn ? 'File is larger than 10 GB. Download may take a long time. Continue?' : 'Файл весит больше 10 ГБ. Скачивание может занять продолжительное время. Продолжить?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -586,7 +610,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
let message = 'Не удалось скачать объект';
|
||||
let message = isEn ? 'Failed to download object' : 'Не удалось скачать объект';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -613,7 +637,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
fetchBucket({ silent: true });
|
||||
dispatchBucketsRefresh();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось удалить объекты';
|
||||
let message = isEn ? 'Failed to delete objects' : 'Не удалось удалить объекты';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -661,7 +685,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
// Проверяем отмену
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Загрузка отменена');
|
||||
throw new Error(isEn ? 'Upload cancelled' : 'Загрузка отменена');
|
||||
}
|
||||
|
||||
const file = files[i];
|
||||
@@ -749,7 +773,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
fetchBucket({ silent: true });
|
||||
dispatchBucketsRefresh();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось загрузить файлы';
|
||||
let message = isEn ? 'Failed to upload files' : 'Не удалось загрузить файлы';
|
||||
if (error instanceof Error && error.message) {
|
||||
message = error.message;
|
||||
}
|
||||
@@ -774,8 +798,8 @@ const StorageBucketPage: React.FC = () => {
|
||||
setUploading(false);
|
||||
setUploadProgress({});
|
||||
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: 0 });
|
||||
addToast('Загрузка отменена', 'info');
|
||||
}, [addToast]);
|
||||
addToast(isEn ? 'Upload cancelled' : 'Загрузка отменена', 'info');
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const handleClickSelectFiles = useCallback(() => {
|
||||
if (fileDialogOpenRef.current || uploading) {
|
||||
@@ -809,7 +833,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
|
||||
const handleUriUpload = useCallback(async () => {
|
||||
if (!uriUploadUrl.trim()) {
|
||||
addToast('Введите URL', 'error');
|
||||
addToast(isEn ? 'Enter URL' : 'Введите URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -865,15 +889,15 @@ const StorageBucketPage: React.FC = () => {
|
||||
addToast(`Файл "${fileName}" загружен`, 'success');
|
||||
} else {
|
||||
console.error('[URI Upload] Ответ не содержит blob:', response.data);
|
||||
addToast('Сервер не вернул данные файла', 'error');
|
||||
addToast(isEn ? 'Server returned no file data' : 'Сервер не вернул данные файла', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[URI Upload] Ошибка:', error);
|
||||
let message = 'Не удалось загрузить по URI';
|
||||
console.error('[URI Upload] Error:', error);
|
||||
let message = isEn ? 'Failed to upload by URI' : 'Не удалось загрузить по URI';
|
||||
if (error instanceof Error && error.message === 'canceled') {
|
||||
message = 'Загрузка отменена';
|
||||
message = isEn ? 'Upload cancelled' : 'Загрузка отменена';
|
||||
} else if (isAxiosError(error) && error.response?.data?.error) {
|
||||
console.error('[URI Upload] Ошибка от сервера:', error.response.data.error);
|
||||
console.error('[URI Upload] Server error:', error.response.data.error);
|
||||
message = error.response.data.error;
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
@@ -883,7 +907,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
setUriUploadLoading(false);
|
||||
uriUploadAbortControllerRef.current = null;
|
||||
}
|
||||
}, [uriUploadUrl, performUpload, addToast, bucketNumber]);
|
||||
}, [uriUploadUrl, performUpload, addToast, bucketNumber, isEn]);
|
||||
|
||||
const handleCancelUriUpload = useCallback(() => {
|
||||
if (uriUploadAbortControllerRef.current) {
|
||||
@@ -891,8 +915,8 @@ const StorageBucketPage: React.FC = () => {
|
||||
uriUploadAbortControllerRef.current = null;
|
||||
}
|
||||
setUriUploadLoading(false);
|
||||
addToast('Загрузка отменена', 'info');
|
||||
}, [addToast]);
|
||||
addToast(isEn ? 'Upload cancelled' : 'Загрузка отменена', 'info');
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const handleUploadInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
@@ -914,7 +938,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
return file.size > 0 || file.type !== '';
|
||||
});
|
||||
if (fileArray.length === 0) {
|
||||
addToast('Папка пуста или не содержит файлов', 'warning');
|
||||
addToast(isEn ? 'Folder is empty or contains no files' : 'Папка пуста или не содержит файлов', 'warning');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -961,7 +985,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
}
|
||||
addToast(`${label} скопирован`, 'success');
|
||||
} catch {
|
||||
addToast('Не удалось скопировать в буфер обмена', 'error');
|
||||
addToast(isEn ? 'Failed to copy to clipboard' : 'Не удалось скопировать в буфер обмена', 'error');
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
@@ -976,10 +1000,10 @@ const StorageBucketPage: React.FC = () => {
|
||||
});
|
||||
setNewKeyLabel('');
|
||||
setLastCreatedKey(data.key);
|
||||
addToast('Создан новый ключ доступа', 'success');
|
||||
addToast(isEn ? 'New access key created' : 'Создан новый ключ доступа', 'success');
|
||||
fetchAccessKeys();
|
||||
} catch (error) {
|
||||
let message = 'Не удалось создать ключ';
|
||||
let message = isEn ? 'Failed to create key' : 'Не удалось создать ключ';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -990,7 +1014,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
}, [addToast, bucketIdValid, bucketNumber, creatingKey, fetchAccessKeys, newKeyLabel]);
|
||||
|
||||
const handleRevokeAccessKey = useCallback(async (keyId: number) => {
|
||||
const confirmed = window.confirm('Удалить ключ доступа? После удаления восстановить его будет невозможно.');
|
||||
const confirmed = window.confirm(isEn ? 'Delete access key? Recovery will be impossible after deletion.' : 'Удалить ключ доступа? После удаления восстановить его будет невозможно.');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -998,9 +1022,9 @@ const StorageBucketPage: React.FC = () => {
|
||||
try {
|
||||
await apiClient.delete(`/api/storage/buckets/${bucketNumber}/access-keys/${keyId}`);
|
||||
setAccessKeys((prev) => prev.filter((key) => key.id !== keyId));
|
||||
addToast('Ключ удалён', 'success');
|
||||
addToast(isEn ? 'Key deleted' : 'Ключ удалён', 'success');
|
||||
} catch (error) {
|
||||
let message = 'Не удалось удалить ключ';
|
||||
let message = isEn ? 'Failed to delete key' : 'Не удалось удалить ключ';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
@@ -1064,7 +1088,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!bucketIdValid) {
|
||||
setBucket(null);
|
||||
setBucketError('Некорректный идентификатор бакета');
|
||||
setBucketError(isEn ? 'Invalid bucket ID' : 'Некорректный идентификатор бакета');
|
||||
setBucketLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -1103,31 +1127,31 @@ const StorageBucketPage: React.FC = () => {
|
||||
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Регион</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Region' : 'Регион'}</p>
|
||||
<p className="font-semibold text-gray-800">{bucket.regionDetails?.name ?? bucket.region}</p>
|
||||
<p className="text-xs text-gray-500">{bucket.regionDetails?.endpoint ?? bucket.regionDetails?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Storage Class' : 'Класс хранения'}</p>
|
||||
<p className="font-semibold text-gray-800">{bucket.storageClassDetails?.name ?? bucket.storageClass}</p>
|
||||
<p className="text-xs text-gray-500">{bucket.storageClassDetails?.description ?? bucket.storageClassDetails?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Тариф</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Plan' : 'Тариф'}</p>
|
||||
<p className="font-semibold text-gray-800">{bucketPlanName}</p>
|
||||
<p className="text-xs text-gray-500">Стоимость: {bucketPrice}</p>
|
||||
<p className="text-xs text-gray-500">{isEn ? 'Cost' : 'Стоимость'}: {bucketPrice}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Биллинг</p>
|
||||
<p className="font-semibold text-gray-800">Следующее списание: {formatDate(bucket.nextBillingDate)}</p>
|
||||
<p className="text-xs text-gray-500">Последнее списание: {formatDate(bucket.lastBilledAt)}</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Billing' : 'Биллинг'}</p>
|
||||
<p className="font-semibold text-gray-800">{isEn ? 'Next charge' : 'Следующее списание'}: {formatDate(bucket.nextBillingDate)}</p>
|
||||
<p className="text-xs text-gray-500">{isEn ? 'Last charge' : 'Последнее списание'}: {formatDate(bucket.lastBilledAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Использовано: {formatBytes(bucket.usedBytes)}</span>
|
||||
<span>Квота: {bucket.quotaGb} GB</span>
|
||||
<span>{isEn ? 'Used' : 'Использовано'}: {formatBytes(bucket.usedBytes)}</span>
|
||||
<span>{isEn ? 'Quota' : 'Квота'}: {bucket.quotaGb} GB</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
@@ -1138,19 +1162,19 @@ const StorageBucketPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-2">
|
||||
<span>{bucketUsagePercent.toFixed(1)}% квоты использовано</span>
|
||||
<span>Объектов: {bucket.objectCount}</span>
|
||||
<span>Синхронизация: {formatDate(bucket.usageSyncedAt, true)}</span>
|
||||
<span>{bucketUsagePercent.toFixed(1)}% {isEn ? 'quota used' : 'квоты использовано'}</span>
|
||||
<span>{isEn ? 'Objects' : 'Объектов'}: {bucket.objectCount}</span>
|
||||
<span>{isEn ? 'Sync' : 'Синхронизация'}: {formatDate(bucket.usageSyncedAt, true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Создан</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Created' : 'Создан'}</p>
|
||||
<p className="font-semibold text-gray-800">{formatDate(bucket.createdAt)}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Обновлён</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Updated' : 'Обновлён'}</p>
|
||||
<p className="font-semibold text-gray-800">{formatDate(bucket.updatedAt, true)}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
@@ -1339,7 +1363,7 @@ const StorageBucketPage: React.FC = () => {
|
||||
<div className="mt-6 space-y-4 bg-ospab-primary/5 p-4 rounded-lg border border-ospab-primary/20">
|
||||
{uploadStats.currentFile && (
|
||||
<div className="text-sm text-gray-700 font-semibold">
|
||||
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
|
||||
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary truncate max-w-[200px] inline-block align-bottom" title={uploadStats.currentFile}>{uploadStats.currentFile}</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadProgress.__total__ && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { StorageBucket } from './types';
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -54,6 +55,8 @@ const StoragePage: React.FC = () => {
|
||||
|
||||
const { addToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [bucketActions, setBucketActions] = useState<Record<number, boolean>>({});
|
||||
|
||||
const fetchBuckets = useCallback(async (notify = false) => {
|
||||
@@ -63,16 +66,16 @@ const StoragePage: React.FC = () => {
|
||||
setBuckets(response.data?.buckets ?? []);
|
||||
setError(null);
|
||||
if (notify) {
|
||||
addToast('Список бакетов обновлён', 'success');
|
||||
addToast(isEn ? 'Bucket list updated' : 'Список бакетов обновлён', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Storage] Не удалось загрузить бакеты', err);
|
||||
setError('Не удалось загрузить список хранилищ');
|
||||
addToast('Не удалось получить список бакетов', 'error');
|
||||
setError(isEn ? 'Failed to load storage list' : 'Не удалось загрузить список хранилищ');
|
||||
addToast(isEn ? 'Failed to get bucket list' : 'Не удалось получить список бакетов', 'error');
|
||||
} finally {
|
||||
setLoadingBuckets(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const fetchStatus = useCallback(async (notify = false) => {
|
||||
try {
|
||||
@@ -80,17 +83,17 @@ const StoragePage: React.FC = () => {
|
||||
const response = await apiClient.get<StorageStatus>('/api/storage/status');
|
||||
setStatus(response.data);
|
||||
if (notify && response.data.minio.connected) {
|
||||
addToast('Подключение к MinIO активно', 'success');
|
||||
addToast(isEn ? 'MinIO connection active' : 'Подключение к MinIO активно', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Storage] Не удалось получить статус', err);
|
||||
if (notify) {
|
||||
addToast('Не удалось обновить статус MinIO', 'warning');
|
||||
addToast(isEn ? 'Failed to update MinIO status' : 'Не удалось обновить статус MinIO', 'warning');
|
||||
}
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
}, [addToast, isEn]);
|
||||
|
||||
const setBucketBusy = useCallback((id: number, busy: boolean) => {
|
||||
setBucketActions((prev) => {
|
||||
@@ -222,9 +225,9 @@ const StoragePage: React.FC = () => {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<FiDatabase className="text-ospab-primary" />
|
||||
S3 Хранилище
|
||||
{isEn ? 'S3 Storage' : 'S3 Хранилище'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">Управление объектными бакетами и статус облачного хранилища</p>
|
||||
<p className="text-gray-600 mt-1">{isEn ? 'Manage object buckets and cloud storage status' : 'Управление объектными бакетами и статус облачного хранилища'}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
@@ -232,14 +235,14 @@ const StoragePage: React.FC = () => {
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
|
||||
>
|
||||
<FiRefreshCw className={loadingBuckets ? 'animate-spin' : ''} />
|
||||
Обновить список
|
||||
{isEn ? 'Refresh list' : 'Обновить список'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
className="px-5 py-2.5 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 shadow-lg transition-all flex items-center gap-2"
|
||||
>
|
||||
<FiPlus />
|
||||
Создать бакет
|
||||
{isEn ? 'Create bucket' : 'Создать бакет'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,9 +264,11 @@ const StoragePage: React.FC = () => {
|
||||
<FiAlertTriangle className="text-red-500 text-2xl" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">Статус подключения MinIO</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-800">{isEn ? 'MinIO Connection Status' : 'Статус подключения MinIO'}</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{minioStatus?.connected ? 'Подключение установлено' : 'Нет связи с хранилищем. Попробуйте обновить статус.'}
|
||||
{minioStatus?.connected
|
||||
? (isEn ? 'Connection established' : 'Подключение установлено')
|
||||
: (isEn ? 'No connection to storage. Try refreshing status.' : 'Нет связи с хранилищем. Попробуйте обновить статус.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,12 +277,12 @@ const StoragePage: React.FC = () => {
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
|
||||
>
|
||||
<FiRefreshCw className={loadingStatus ? 'animate-spin' : ''} />
|
||||
Проверить статус
|
||||
{isEn ? 'Check status' : 'Проверить статус'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingStatus ? (
|
||||
<div className="px-6 py-8 text-sm text-gray-500">Проверяем подключение к MinIO...</div>
|
||||
<div className="px-6 py-8 text-sm text-gray-500">{isEn ? 'Checking MinIO connection...' : 'Проверяем подключение к MinIO...'}</div>
|
||||
) : status ? (
|
||||
<div className="px-6 py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
@@ -287,11 +292,11 @@ const StoragePage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FiInfo className="text-ospab-primary" />
|
||||
<span>Префикс бакетов: <span className="font-semibold text-gray-800">{minioStatus?.bucketPrefix || '—'}</span></span>
|
||||
<span>{isEn ? 'Bucket prefix:' : 'Префикс бакетов:'} <span className="font-semibold text-gray-800">{minioStatus?.bucketPrefix || '—'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FiInfo className="text-ospab-primary" />
|
||||
<span>Всего бакетов на сервере: <span className="font-semibold text-gray-800">{minioStatus?.availableBuckets ?? '—'}</span></span>
|
||||
<span>{isEn ? 'Total buckets on server:' : 'Всего бакетов на сервере:'} <span className="font-semibold text-gray-800">{minioStatus?.availableBuckets ?? '—'}</span></span>
|
||||
</div>
|
||||
{minioStatus?.error && !minioStatus.connected && (
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
@@ -303,45 +308,45 @@ const StoragePage: React.FC = () => {
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Регион по умолчанию</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.region?.name ?? 'Не выбран'}</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Default region' : 'Регион по умолчанию'}</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.region?.name ?? (isEn ? 'Not selected' : 'Не выбран')}</p>
|
||||
<p className="text-xs text-gray-500">{status.defaults.region?.endpoint ?? status.defaults.region?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения по умолчанию</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.storageClass?.name ?? 'Не выбран'}</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Default storage class' : 'Класс хранения по умолчанию'}</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.storageClass?.name ?? (isEn ? 'Not selected' : 'Не выбран')}</p>
|
||||
<p className="text-xs text-gray-500">{status.defaults.storageClass?.description ?? status.defaults.storageClass?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<span>Активных тарифов: <span className="font-semibold text-gray-800">{status.plans.length}</span></span>
|
||||
<span>Регионов: <span className="font-semibold text-gray-800">{status.regions.length}</span></span>
|
||||
<span>Классов хранения: <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
|
||||
<span>{isEn ? 'Active plans:' : 'Активных тарифов:'} <span className="font-semibold text-gray-800">{status.plans.length}</span></span>
|
||||
<span>{isEn ? 'Regions:' : 'Регионов:'} <span className="font-semibold text-gray-800">{status.regions.length}</span></span>
|
||||
<span>{isEn ? 'Storage classes:' : 'Классов хранения:'} <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-8 text-sm text-gray-500 flex items-center gap-2">
|
||||
<FiInfo />
|
||||
Нет данных о статусе хранилища. Попробуйте обновить.
|
||||
{isEn ? 'No storage status data. Try refreshing.' : 'Нет данных о статусе хранилища. Попробуйте обновить.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Всего бакетов</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">{isEn ? 'Total buckets' : 'Всего бакетов'}</p>
|
||||
<p className="text-3xl font-bold text-gray-800">{summary.totalBuckets}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Автопродление активировано: {summary.autoRenewCount}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{isEn ? 'Auto-renewal enabled:' : 'Автопродление активировано:'} {summary.autoRenewCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Использовано данных</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">{isEn ? 'Data used' : 'Использовано данных'}</p>
|
||||
<p className="text-2xl font-semibold text-gray-800">{formatBytes(summary.totalUsedBytes)}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Глобальная загрузка: {summary.globalUsagePercent.toFixed(1)}%</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{isEn ? 'Global usage:' : 'Глобальная загрузка:'} {summary.globalUsagePercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Суммарная квота</p>
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">{isEn ? 'Total quota' : 'Суммарная квота'}</p>
|
||||
<p className="text-2xl font-semibold text-gray-800">{summary.totalQuotaGb} GB</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Мин. ежемесячный тариф: {summary.lowestPrice !== null ? formatCurrency(summary.lowestPrice) : '—'}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">{isEn ? 'Min. monthly rate:' : 'Мин. ежемесячный тариф:'} {summary.lowestPrice !== null ? formatCurrency(summary.lowestPrice) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,14 +358,14 @@ const StoragePage: React.FC = () => {
|
||||
) : buckets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<FiDatabase className="text-6xl text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Нет активных хранилищ</h3>
|
||||
<p className="text-gray-600 mb-6">Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.</p>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">{isEn ? 'No active storage' : 'Нет активных хранилищ'}</h3>
|
||||
<p className="text-gray-600 mb-6">{isEn ? 'Create your first S3 bucket for storing files, backups and media content.' : 'Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 shadow-lg transition-all inline-flex items-center gap-2"
|
||||
>
|
||||
<FiPlus />
|
||||
Выбрать тариф
|
||||
{isEn ? 'Choose plan' : 'Выбрать тариф'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -384,7 +389,7 @@ const StoragePage: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-gray-800">{bucket.name}</h3>
|
||||
<h3 className="text-lg font-bold text-gray-800 truncate max-w-[200px] sm:max-w-[300px]" title={bucket.name}>{bucket.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${planTone}`}>
|
||||
{planName}
|
||||
</span>
|
||||
@@ -392,7 +397,7 @@ const StoragePage: React.FC = () => {
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">ID бакета: {bucket.id}</p>
|
||||
<p className="text-xs text-gray-500">{isEn ? 'Bucket ID:' : 'ID бакета:'} {bucket.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
import type { UserData, Ticket } from './types';
|
||||
|
||||
@@ -7,6 +8,9 @@ interface SummaryProps {
|
||||
}
|
||||
|
||||
const Summary = ({ userData }: SummaryProps) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
// Фильтрация открытых тикетов
|
||||
const openTickets = Array.isArray(userData.tickets)
|
||||
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
|
||||
@@ -14,21 +18,30 @@ const Summary = ({ userData }: SummaryProps) => {
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl">
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">Сводка по аккаунту</h2>
|
||||
<h2 className="text-2xl lg:text-3xl font-bold text-gray-800 mb-4 lg:mb-6">
|
||||
{isEn ? 'Account Summary' : 'Сводка по аккаунту'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6 mb-6 lg:mb-8">
|
||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl flex flex-col items-start">
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">Баланс:</p>
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">{isEn ? 'Balance:' : 'Баланс:'}</p>
|
||||
<p className="text-3xl lg:text-4xl font-extrabold text-ospab-primary mt-2 break-words">₽ {userData.balance?.toFixed ? userData.balance.toFixed(2) : Number(userData.balance).toFixed(2)}</p>
|
||||
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">Пополнить баланс →</Link>
|
||||
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">
|
||||
{isEn ? 'Top up balance →' : 'Пополнить баланс →'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl flex flex-col items-start">
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">Открытые тикеты:</p>
|
||||
<p className="text-lg lg:text-xl font-medium text-gray-700">{isEn ? 'Open Tickets:' : 'Открытые тикеты:'}</p>
|
||||
<p className="text-3xl lg:text-4xl font-extrabold text-gray-800 mt-2">{openTickets.length}</p>
|
||||
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">Служба поддержки →</Link>
|
||||
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">
|
||||
{isEn ? 'Support →' : 'Служба поддержки →'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base lg:text-lg text-gray-500">
|
||||
Добро пожаловать в ваш личный кабинет, {userData.user?.username || 'пользователь'}! Здесь вы можете быстро получить доступ к основным разделам.
|
||||
{isEn
|
||||
? `Welcome to your dashboard, ${userData.user?.username || 'user'}! Here you can quickly access the main sections.`
|
||||
: `Добро пожаловать в ваш личный кабинет, ${userData.user?.username || 'пользователь'}! Здесь вы можете быстро получить доступ к основным разделам.`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UserData } from './types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
@@ -35,6 +36,8 @@ type TicketsPageProps = {
|
||||
|
||||
const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
const navigate = useNavigate();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -69,11 +72,11 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт' }
|
||||
open: { color: 'bg-green-100 text-green-800', text: isEn ? 'Open' : 'Открыт' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: isEn ? 'In Progress' : 'В работе' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: isEn ? 'Awaiting Reply' : 'Ожидает ответа' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: isEn ? 'Resolved' : 'Решён' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: isEn ? 'Closed' : 'Закрыт' }
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.open;
|
||||
@@ -87,10 +90,10 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий' }
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: isEn ? 'Urgent' : 'Срочно' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: isEn ? 'High' : 'Высокий' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: isEn ? 'Normal' : 'Обычный' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: isEn ? 'Low' : 'Низкий' }
|
||||
};
|
||||
|
||||
const badge = badges[priority] || badges.normal;
|
||||
@@ -121,11 +124,11 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -133,7 +136,7 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка тикетов...</p>
|
||||
<p className="mt-4 text-gray-600">{isEn ? 'Loading tickets...' : 'Загрузка тикетов...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -145,15 +148,15 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Управляйте вашими обращениями в службу поддержки</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||
<p className="text-gray-600">{isEn ? 'Manage your support requests' : 'Управляйте вашими обращениями в службу поддержки'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>➕</span>
|
||||
Создать тикет
|
||||
{isEn ? 'Create Ticket' : 'Создать тикет'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -162,50 +165,50 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Статус</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Status' : 'Статус'}</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="awaiting_reply">Ожидает ответа</option>
|
||||
<option value="resolved">Решён</option>
|
||||
<option value="closed">Закрыт</option>
|
||||
<option value="all">{isEn ? 'All Statuses' : 'Все статусы'}</option>
|
||||
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Категория</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Category' : 'Категория'}</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="all">{isEn ? 'All Categories' : 'Все категории'}</option>
|
||||
<option value="general">{isEn ? 'General Questions' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Приоритет</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{isEn ? 'Priority' : 'Приоритет'}</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="all">{isEn ? 'All Priorities' : 'Все приоритеты'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,13 +217,13 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
{/* Tickets Grid */}
|
||||
{tickets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
|
||||
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{isEn ? 'No Tickets' : 'Нет тикетов'}</h3>
|
||||
<p className="text-gray-600 mb-6">{isEn ? 'You have no open support tickets yet' : 'У вас пока нет открытых тикетов поддержки'}</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Создать первый тикет
|
||||
{isEn ? 'Create First Ticket' : 'Создать первый тикет'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -251,13 +254,13 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||
<span>{ticket.responses?.length || 0} ответов</span>
|
||||
<span>{ticket.responses?.length || 0} {isEn ? 'replies' : 'ответов'}</span>
|
||||
{ticket.closedAt && (
|
||||
<span>Закрыт</span>
|
||||
<span>{isEn ? 'Closed' : 'Закрыт'}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
||||
Открыть →
|
||||
{isEn ? 'Open →' : 'Открыть →'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
@@ -48,7 +49,7 @@ interface TicketDetail {
|
||||
responses: TicketResponse[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
const STATUS_LABELS_RU: Record<string, { text: string; badge: string }> = {
|
||||
open: { text: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { text: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { text: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
@@ -56,18 +57,38 @@ const STATUS_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
closed: { text: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
const STATUS_LABELS_EN: Record<string, { text: string; badge: string }> = {
|
||||
open: { text: 'Open', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { text: 'In Progress', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { text: 'Awaiting Reply', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { text: 'Resolved', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { text: 'Closed', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS_RU: Record<string, { text: string; badge: string }> = {
|
||||
urgent: { text: 'Срочно', badge: 'bg-red-100 text-red-800' },
|
||||
high: { text: 'Высокий', badge: 'bg-orange-100 text-orange-800' },
|
||||
normal: { text: 'Обычный', badge: 'bg-gray-100 text-gray-800' },
|
||||
low: { text: 'Низкий', badge: 'bg-green-100 text-green-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS_EN: Record<string, { text: string; badge: string }> = {
|
||||
urgent: { text: 'Urgent', badge: 'bg-red-100 text-red-800' },
|
||||
high: { text: 'High', badge: 'bg-orange-100 text-orange-800' },
|
||||
normal: { text: 'Normal', badge: 'bg-gray-100 text-gray-800' },
|
||||
low: { text: 'Low', badge: 'bg-green-100 text-green-800' },
|
||||
};
|
||||
|
||||
const TicketDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const STATUS_LABELS = isEn ? STATUS_LABELS_EN : STATUS_LABELS_RU;
|
||||
const PRIORITY_LABELS = isEn ? PRIORITY_LABELS_EN : PRIORITY_LABELS_RU;
|
||||
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
const currentUserId = userData?.user?.id ?? null;
|
||||
@@ -85,7 +106,7 @@ const TicketDetailPage = () => {
|
||||
|
||||
const fetchTicket = async () => {
|
||||
if (!ticketId) {
|
||||
setError('Некорректный идентификатор тикета');
|
||||
setError(isEn ? 'Invalid ticket ID' : 'Некорректный идентификатор тикета');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -99,7 +120,7 @@ const TicketDetailPage = () => {
|
||||
setTicket(payload);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки тикета:', err);
|
||||
setError('Не удалось загрузить тикет');
|
||||
setError(isEn ? 'Failed to load ticket' : 'Не удалось загрузить тикет');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -138,11 +159,11 @@ const TicketDetailPage = () => {
|
||||
|
||||
setReply('');
|
||||
setIsInternalNote(false);
|
||||
addToast('Ответ отправлен', 'success');
|
||||
addToast(isEn ? 'Reply sent' : 'Ответ отправлен', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка отправки ответа:', err);
|
||||
addToast('Не удалось отправить ответ', 'error');
|
||||
addToast(isEn ? 'Failed to send reply' : 'Не удалось отправить ответ', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -151,17 +172,17 @@ const TicketDetailPage = () => {
|
||||
const handleCloseTicket = async () => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const confirmation = window.confirm('Вы уверены, что хотите закрыть тикет?');
|
||||
const confirmation = window.confirm(isEn ? 'Are you sure you want to close this ticket?' : 'Вы уверены, что хотите закрыть тикет?');
|
||||
if (!confirmation) return;
|
||||
|
||||
setStatusProcessing(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/close', { ticketId });
|
||||
addToast('Тикет закрыт', 'success');
|
||||
addToast(isEn ? 'Ticket closed' : 'Тикет закрыт', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка закрытия тикета:', err);
|
||||
addToast('Не удалось закрыть тикет', 'error');
|
||||
addToast(isEn ? 'Failed to close ticket' : 'Не удалось закрыть тикет', 'error');
|
||||
} finally {
|
||||
setStatusProcessing(false);
|
||||
}
|
||||
@@ -173,11 +194,11 @@ const TicketDetailPage = () => {
|
||||
setStatusProcessing(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/status', { ticketId, status });
|
||||
addToast('Статус обновлён', 'success');
|
||||
addToast(isEn ? 'Status updated' : 'Статус обновлён', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка изменения статуса:', err);
|
||||
addToast('Не удалось изменить статус', 'error');
|
||||
addToast(isEn ? 'Failed to update status' : 'Не удалось изменить статус', 'error');
|
||||
} finally {
|
||||
setStatusProcessing(false);
|
||||
}
|
||||
@@ -189,11 +210,11 @@ const TicketDetailPage = () => {
|
||||
setAssigning(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/assign', { ticketId, operatorId: currentUserId });
|
||||
addToast('Тикет назначен на вас', 'success');
|
||||
addToast(isEn ? 'Ticket assigned to you' : 'Тикет назначен на вас', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка назначения тикета:', err);
|
||||
addToast('Не удалось назначить тикет', 'error');
|
||||
addToast(isEn ? 'Failed to assign ticket' : 'Не удалось назначить тикет', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
@@ -217,7 +238,7 @@ const TicketDetailPage = () => {
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="mt-4 text-sm text-gray-600">Загрузка тикета...</p>
|
||||
<p className="mt-4 text-sm text-gray-600">{isEn ? 'Loading ticket...' : 'Загрузка тикета...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -227,10 +248,10 @@ const TicketDetailPage = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-red-600">
|
||||
<h2 className="text-lg font-semibold">Ошибка</h2>
|
||||
<h2 className="text-lg font-semibold">{isEn ? 'Error' : 'Ошибка'}</h2>
|
||||
<p className="mt-2 text-sm">{error}</p>
|
||||
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-red-700">
|
||||
← Вернуться к тикетам
|
||||
← {isEn ? 'Back to tickets' : 'Вернуться к тикетам'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,10 +262,10 @@ const TicketDetailPage = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md rounded-2xl border border-gray-200 bg-white p-6 text-center shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Тикет не найден</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">Возможно, он был удалён или у вас нет доступа.</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'Ticket not found' : 'Тикет не найден'}</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">{isEn ? 'It may have been deleted or you do not have access.' : 'Возможно, он был удалён или у вас нет доступа.'}</p>
|
||||
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-blue-600">
|
||||
← Вернуться к списку
|
||||
← {isEn ? 'Back to list' : 'Вернуться к списку'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +282,7 @@ const TicketDetailPage = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition hover:text-gray-800"
|
||||
>
|
||||
← Назад
|
||||
← {isEn ? 'Back' : 'Назад'}
|
||||
</button>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
@@ -273,7 +294,7 @@ const TicketDetailPage = () => {
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${priorityMeta.badge}`}>
|
||||
{priorityMeta.text}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">Категория: {ticket.category}</span>
|
||||
<span className="text-sm text-gray-500">{isEn ? 'Category' : 'Категория'}: {ticket.category}</span>
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
|
||||
{ticket.assignedOperator.username}
|
||||
@@ -282,9 +303,9 @@ const TicketDetailPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-sm text-gray-600">
|
||||
<span>Создан: {formatDateTime(ticket.createdAt)}</span>
|
||||
<span>Обновлён: {formatDateTime(ticket.updatedAt)}</span>
|
||||
{ticket.closedAt && <span>Закрыт: {formatDateTime(ticket.closedAt)}</span>}
|
||||
<span>{isEn ? 'Created' : 'Создан'}: {formatDateTime(ticket.createdAt)}</span>
|
||||
<span>{isEn ? 'Updated' : 'Обновлён'}: {formatDateTime(ticket.updatedAt)}</span>
|
||||
{ticket.closedAt && <span>{isEn ? 'Closed' : 'Закрыт'}: {formatDateTime(ticket.closedAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,12 +315,14 @@ const TicketDetailPage = () => {
|
||||
|
||||
{ticket.attachments.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Вложенные файлы</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-700">{isEn ? 'Attached Files' : 'Вложенные файлы'}</h3>
|
||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm text-blue-600">
|
||||
{ticket.attachments.map((attachment) => (
|
||||
<li key={attachment.id}>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
|
||||
📎 {attachment.filename}
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100 max-w-full">
|
||||
<span className="truncate max-w-[200px] sm:max-w-[300px] md:max-w-none" title={attachment.filename}>
|
||||
📎 {attachment.filename}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@@ -315,7 +338,7 @@ const TicketDetailPage = () => {
|
||||
disabled={assigning}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{assigning ? 'Назначаю...' : 'Взять в работу'}
|
||||
{assigning ? (isEn ? 'Assigning...' : 'Назначаю...') : (isEn ? 'Take on' : 'Взять в работу')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -328,7 +351,7 @@ const TicketDetailPage = () => {
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-green-200 px-4 py-2 text-sm font-semibold text-green-600 transition hover:border-green-300 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{statusProcessing ? 'Сохранение...' : 'Отметить как решён'}
|
||||
{statusProcessing ? (isEn ? 'Saving...' : 'Сохранение...') : (isEn ? 'Mark as Resolved' : 'Отметить как решён')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -337,7 +360,7 @@ const TicketDetailPage = () => {
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Закрыть тикет
|
||||
{isEn ? 'Close Ticket' : 'Закрыть тикет'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -349,18 +372,18 @@ const TicketDetailPage = () => {
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Возобновить работу
|
||||
{isEn ? 'Reopen' : 'Возобновить работу'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">История общения</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'Conversation History' : 'История общения'}</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{ticket.responses.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
|
||||
Ответов пока нет. Напишите первое сообщение, чтобы ускорить решение.
|
||||
{isEn ? 'No replies yet. Write the first message to speed up resolution.' : 'Ответов пока нет. Напишите первое сообщение, чтобы ускорить решение.'}
|
||||
</p>
|
||||
) : (
|
||||
ticket.responses.map((response) => {
|
||||
@@ -375,15 +398,15 @@ const TicketDetailPage = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{response.author?.username ?? 'Неизвестно'}</span>
|
||||
<span className="font-semibold text-gray-900">{response.author?.username ?? (isEn ? 'Unknown' : 'Неизвестно')}</span>
|
||||
{isResponseOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
Оператор
|
||||
{isEn ? 'Operator' : 'Оператор'}
|
||||
</span>
|
||||
)}
|
||||
{response.isInternal && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-700">
|
||||
Внутренний комментарий
|
||||
{isEn ? 'Internal Comment' : 'Внутренний комментарий'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{formatDateTime(response.createdAt)}</span>
|
||||
@@ -394,8 +417,10 @@ const TicketDetailPage = () => {
|
||||
<ul className="mt-3 flex flex-wrap gap-2 text-sm text-blue-600">
|
||||
{response.attachments.map((attachment) => (
|
||||
<li key={attachment.id}>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
|
||||
📎 {attachment.filename}
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100 max-w-full">
|
||||
<span className="truncate max-w-[200px] sm:max-w-[300px] md:max-w-none" title={attachment.filename}>
|
||||
📎 {attachment.filename}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
@@ -410,11 +435,11 @@ const TicketDetailPage = () => {
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Новый ответ</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'New Reply' : 'Новый ответ'}</h2>
|
||||
<textarea
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder="Опишите детали, приложите решение или уточнение..."
|
||||
placeholder={isEn ? 'Describe details, attach solution or clarification...' : 'Опишите детали, приложите решение или уточнение...'}
|
||||
className="mt-3 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
rows={6}
|
||||
/>
|
||||
@@ -427,7 +452,7 @@ const TicketDetailPage = () => {
|
||||
onChange={(event) => setIsInternalNote(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Внутренний комментарий (видно только операторам)
|
||||
{isEn ? 'Internal comment (visible only to operators)' : 'Внутренний комментарий (видно только операторам)'}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -438,7 +463,7 @@ const TicketDetailPage = () => {
|
||||
disabled={sending || reply.length === 0}
|
||||
className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Очистить
|
||||
{isEn ? 'Clear' : 'Очистить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -446,7 +471,7 @@ const TicketDetailPage = () => {
|
||||
disabled={sending || !reply.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить'}
|
||||
{sending ? (isEn ? 'Sending...' : 'Отправка...') : (isEn ? 'Send' : 'Отправить')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
@@ -66,7 +67,7 @@ interface TicketStats {
|
||||
unassigned?: number;
|
||||
}
|
||||
|
||||
const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
const STATUS_DICTIONARY_RU: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
@@ -74,21 +75,41 @@ const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
closed: { label: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
const STATUS_DICTIONARY_EN: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Open', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'In Progress', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Awaiting Reply', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { label: 'Resolved', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { label: 'Closed', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY_RU: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Срочно', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'Высокий', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Обычный', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Низкий', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY_EN: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Urgent', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'High', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Normal', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Low', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
|
||||
|
||||
const TicketsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
|
||||
const STATUS_DICTIONARY = isEn ? STATUS_DICTIONARY_EN : STATUS_DICTIONARY_RU;
|
||||
const PRIORITY_DICTIONARY = isEn ? PRIORITY_DICTIONARY_EN : PRIORITY_DICTIONARY_RU;
|
||||
|
||||
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||
const [meta, setMeta] = useState<TicketListMeta>({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false });
|
||||
const [stats, setStats] = useState<TicketStats>({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
|
||||
@@ -182,29 +203,29 @@ const TicketsPage = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMinutes < 1) return 'только что';
|
||||
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMinutes < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMinutes < 60) return isEn ? `${diffMinutes} min ago` : `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours} h ago` : `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays} d ago` : `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
const statusCards = useMemo(() => {
|
||||
if (isOperator) {
|
||||
return [
|
||||
{ title: 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
{ title: isEn ? 'Open' : 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: isEn ? 'Awaiting Reply' : 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: isEn ? 'Assigned to me' : 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: isEn ? 'Unassigned' : 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ title: 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
{ title: isEn ? 'Active' : 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: isEn ? 'Awaiting Reply' : 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: isEn ? 'Closed' : 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}, [isOperator, stats]);
|
||||
}, [isOperator, stats, isEn]);
|
||||
|
||||
const handleChangePage = (nextPage: number) => {
|
||||
setMeta((prev) => ({ ...prev, page: nextPage }));
|
||||
@@ -215,14 +236,14 @@ const TicketsPage = () => {
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Создавайте обращения и следите за их обработкой в режиме реального времени.</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||
<p className="text-gray-600">{isEn ? 'Create tickets and track their processing in real time.' : 'Создавайте обращения и следите за их обработкой в режиме реального времени.'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
Новый тикет
|
||||
{isEn ? 'New Ticket' : 'Новый тикет'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -240,69 +261,69 @@ const TicketsPage = () => {
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Статус</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Status' : 'Статус'}</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="awaiting_reply">Ожидает ответа</option>
|
||||
<option value="resolved">Решён</option>
|
||||
<option value="closed">Закрыт</option>
|
||||
<option value="all">{isEn ? 'All statuses' : 'Все статусы'}</option>
|
||||
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Категория</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Category' : 'Категория'}</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, category: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="all">{isEn ? 'All categories' : 'Все категории'}</option>
|
||||
<option value="general">{isEn ? 'General' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Приоритет</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Priority' : 'Приоритет'}</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="all">{isEn ? 'All priorities' : 'Все приоритеты'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
</select>
|
||||
</div>
|
||||
{isOperator && (
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Назначение</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Assignment' : 'Назначение'}</label>
|
||||
<select
|
||||
value={filters.assigned}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, assigned: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="me">Мои тикеты</option>
|
||||
<option value="unassigned">Без оператора</option>
|
||||
<option value="others">Назначены другим</option>
|
||||
<option value="all">{isEn ? 'All' : 'Все'}</option>
|
||||
<option value="me">{isEn ? 'My tickets' : 'Мои тикеты'}</option>
|
||||
<option value="unassigned">{isEn ? 'Unassigned' : 'Без оператора'}</option>
|
||||
<option value="others">{isEn ? 'Assigned to others' : 'Назначены другим'}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={isOperator ? 'lg:col-span-4' : 'lg:col-span-6'}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Поиск</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Search' : 'Поиск'}</label>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск по теме или описанию..."
|
||||
placeholder={isEn ? 'Search by subject or description...' : 'Поиск по теме или описанию...'}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
@@ -313,29 +334,29 @@ const TicketsPage = () => {
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка тикетов...</p>
|
||||
<p className="text-sm text-gray-500">{isEn ? 'Loading tickets...' : 'Загрузка тикетов...'}</p>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Тикетов пока нет</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{isEn ? 'No tickets yet' : 'Тикетов пока нет'}</h3>
|
||||
<p className="max-w-md text-sm text-gray-500">
|
||||
Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.
|
||||
{isEn ? 'Create a ticket so the support team can help. We are always here.' : 'Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.'}
|
||||
</p>
|
||||
<Link
|
||||
to="/dashboard/tickets/new"
|
||||
className="rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
Создать первый тикет
|
||||
{isEn ? 'Create first ticket' : 'Создать первый тикет'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden w-full grid-cols-[100px_1fr_160px_160px_160px] gap-4 border-b border-gray-100 px-6 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 lg:grid">
|
||||
<span>ID</span>
|
||||
<span>Тема</span>
|
||||
<span>Статус</span>
|
||||
<span>Приоритет</span>
|
||||
<span>Обновлён</span>
|
||||
<span>{isEn ? 'Subject' : 'Тема'}</span>
|
||||
<span>{isEn ? 'Status' : 'Статус'}</span>
|
||||
<span>{isEn ? 'Priority' : 'Приоритет'}</span>
|
||||
<span>{isEn ? 'Updated' : 'Обновлён'}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{tickets.map((ticket) => {
|
||||
@@ -358,7 +379,7 @@ const TicketsPage = () => {
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{ticket.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700 truncate max-w-[120px]" title={ticket.assignedOperator.username}>
|
||||
{ticket.assignedOperator.username}
|
||||
</span>
|
||||
)}
|
||||
@@ -367,7 +388,7 @@ const TicketsPage = () => {
|
||||
{ticket.responseCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500 truncate max-w-[120px]" title={ticket.user?.username ?? 'Неизвестно'}>
|
||||
{ticket.user?.username ?? 'Неизвестно'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,13 @@ import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
const NewTicketPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
message: '',
|
||||
@@ -19,7 +22,7 @@ const NewTicketPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.title.trim() || !formData.message.trim()) {
|
||||
setError('Заполните все поля');
|
||||
setError(isEn ? 'Please fill in all fields' : 'Заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,12 +33,12 @@ const NewTicketPage: React.FC = () => {
|
||||
const response = await apiClient.post('/api/ticket/create', formData);
|
||||
|
||||
// Перенаправляем на созданный тикет
|
||||
addToast('Тикет создан и отправлен в поддержку', 'success');
|
||||
addToast(isEn ? 'Ticket created and sent to support' : 'Тикет создан и отправлен в поддержку', 'success');
|
||||
navigate(`/dashboard/tickets/${response.data.ticket.id}`);
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания тикета:', err);
|
||||
setError('Не удалось создать тикет. Попробуйте ещё раз.');
|
||||
addToast('Не удалось создать тикет', 'error');
|
||||
setError(isEn ? 'Failed to create ticket. Please try again.' : 'Не удалось создать тикет. Попробуйте ещё раз.');
|
||||
addToast(isEn ? 'Failed to create ticket' : 'Не удалось создать тикет', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -50,12 +53,12 @@ const NewTicketPage: React.FC = () => {
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Назад к тикетам</span>
|
||||
<span>{isEn ? 'Back to tickets' : 'Назад к тикетам'}</span>
|
||||
</Link>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-xl shadow-md p-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Создать новый тикет</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEn ? 'Create New Ticket' : 'Создать новый тикет'}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
|
||||
@@ -67,13 +70,13 @@ const NewTicketPage: React.FC = () => {
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Тема <span className="text-red-500">*</span>
|
||||
{isEn ? 'Subject' : 'Тема'} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Кратко опишите вашу проблему"
|
||||
placeholder={isEn ? 'Briefly describe your issue' : 'Кратко опишите вашу проблему'}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
@@ -84,34 +87,34 @@ const NewTicketPage: React.FC = () => {
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Категория
|
||||
{isEn ? 'Category' : 'Категория'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="general">{isEn ? 'General Questions' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Приоритет
|
||||
{isEn ? 'Priority' : 'Приоритет'}
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,29 +122,29 @@ const NewTicketPage: React.FC = () => {
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Описание <span className="text-red-500">*</span>
|
||||
{isEn ? 'Description' : 'Описание'} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder="Подробно опишите вашу проблему или вопрос..."
|
||||
placeholder={isEn ? 'Describe your issue or question in detail...' : 'Подробно опишите вашу проблему или вопрос...'}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={8}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Минимум 10 символов. Чем подробнее вы опишете проблему, тем быстрее мы сможем помочь.
|
||||
{isEn ? 'Minimum 10 characters. The more details you provide, the faster we can help.' : 'Минимум 10 символов. Чем подробнее вы опишете проблему, тем быстрее мы сможем помочь.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">💡 Советы:</h3>
|
||||
<h3 className="text-sm font-semibold text-blue-900 mb-2">💡 {isEn ? 'Tips:' : 'Советы:'}</h3>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• Укажите все детали проблемы</li>
|
||||
<li>• Приложите скриншоты, если возможно</li>
|
||||
<li>• Опишите шаги для воспроизведения ошибки</li>
|
||||
<li>• Среднее время ответа: 2-4 часа</li>
|
||||
<li>• {isEn ? 'Include all details of the issue' : 'Укажите все детали проблемы'}</li>
|
||||
<li>• {isEn ? 'Attach screenshots if possible' : 'Приложите скриншоты, если возможно'}</li>
|
||||
<li>• {isEn ? 'Describe steps to reproduce the error' : 'Опишите шаги для воспроизведения ошибки'}</li>
|
||||
<li>• {isEn ? 'Average response time: 2-4 hours' : 'Среднее время ответа: 2-4 часа'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -151,14 +154,14 @@ const NewTicketPage: React.FC = () => {
|
||||
to="/dashboard/tickets"
|
||||
className="px-6 py-3 text-gray-700 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Отмена
|
||||
{isEn ? 'Cancel' : 'Отмена'}
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending ? 'Создание...' : 'Создать тикет'}
|
||||
{sending ? (isEn ? 'Creating...' : 'Создание...') : (isEn ? 'Create Ticket' : 'Создать тикет')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
250
ospabhost/frontend/src/pages/errors/CloudflareError.tsx
Normal file
250
ospabhost/frontend/src/pages/errors/CloudflareError.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface CloudflareErrorProps {
|
||||
errorCode: string;
|
||||
title: string;
|
||||
description: string;
|
||||
whatHappened: string;
|
||||
whatCanIDo?: string;
|
||||
rayId?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
const CloudflareError: React.FC<CloudflareErrorProps> = ({
|
||||
errorCode,
|
||||
title,
|
||||
description,
|
||||
whatHappened,
|
||||
whatCanIDo,
|
||||
rayId,
|
||||
ip,
|
||||
}) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const currentTime = new Date().toUTCString();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header Bar */}
|
||||
<div className="bg-[#f38020] h-2" />
|
||||
|
||||
{/* Main Container */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Error Header */}
|
||||
<div className="border-b-4 border-[#f38020] pb-6 mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h1 className="text-6xl font-bold text-gray-800">
|
||||
{isEn ? 'Error' : 'Ошибка'} {errorCode}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
||||
{rayId && (
|
||||
<>
|
||||
<span className="font-mono">Ray ID: {rayId}</span>
|
||||
<span>•</span>
|
||||
</>
|
||||
)}
|
||||
<span>{currentTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Title */}
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-8">{title}</h2>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* What happened */}
|
||||
<div className="bg-gray-50 rounded-xl p-6 border-l-4 border-[#f38020]">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-[#f38020]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{isEn ? 'What happened?' : 'Что произошло?'}
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{whatHappened}</p>
|
||||
</div>
|
||||
|
||||
{/* What can I do */}
|
||||
{whatCanIDo && (
|
||||
<div className="bg-gray-50 rounded-xl p-6 border-l-4 border-blue-500">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
{isEn ? 'What can I do?' : 'Что я могу сделать?'}
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{whatCanIDo}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-8 p-6 bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl">
|
||||
<p className="text-gray-600 text-sm">{description}</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-12 pt-8 border-t border-gray-200">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{rayId && (
|
||||
<span className="mr-4">
|
||||
ospab.host Ray ID: <span className="font-mono">{rayId}</span>
|
||||
</span>
|
||||
)}
|
||||
{ip && (
|
||||
<span>
|
||||
{isEn ? 'Your IP:' : 'Ваш IP:'}{' '}
|
||||
<button
|
||||
className="text-blue-600 hover:underline"
|
||||
onClick={() => alert(ip)}
|
||||
>
|
||||
{isEn ? 'Click to reveal' : 'Нажмите для показа'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>{isEn ? 'Performance & security by' : 'Производительность и безопасность'}</span>
|
||||
<span className="font-bold text-[#f38020]">ospab.host</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="mt-6 text-center">
|
||||
<span className="text-sm text-gray-500">
|
||||
{isEn ? 'Was this page helpful?' : 'Была ли эта страница полезной?'}
|
||||
</span>
|
||||
<div className="inline-flex gap-2 ml-4">
|
||||
<button className="px-4 py-1 border border-gray-300 rounded hover:bg-gray-100 text-sm">
|
||||
{isEn ? 'Yes' : 'Да'}
|
||||
</button>
|
||||
<button className="px-4 py-1 border border-gray-300 rounded hover:bg-gray-100 text-sm">
|
||||
{isEn ? 'No' : 'Нет'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudflareError;
|
||||
|
||||
// Pre-configured error pages
|
||||
export const Error1000: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="1000"
|
||||
title={isEn ? 'DNS points to prohibited IP' : 'DNS указывает на запрещённый IP'}
|
||||
description={isEn
|
||||
? 'Please see https://ospab.host/docs/errors/1000 for more details.'
|
||||
: 'Подробнее см. https://ospab.host/docs/errors/1000'}
|
||||
whatHappened={isEn
|
||||
? "You've requested a page on a website that is on the ospab.host network. Unfortunately, it is resolving to an IP address that is creating a conflict within the system."
|
||||
: 'Вы запросили страницу на сайте, который находится в сети ospab.host. К сожалению, он разрешается в IP-адрес, который создаёт конфликт в системе.'}
|
||||
whatCanIDo={isEn
|
||||
? 'If you are the owner of this website: you should login to ospab.host dashboard and change the DNS A records to resolve to a different IP address.'
|
||||
: 'Если вы владелец этого сайта: войдите в панель управления ospab.host и измените DNS A-записи, чтобы они указывали на другой IP-адрес.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error502: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="502"
|
||||
title={isEn ? 'Bad Gateway' : 'Плохой шлюз'}
|
||||
description={isEn
|
||||
? 'The web server reported a bad gateway error.'
|
||||
: 'Веб-сервер сообщил об ошибке шлюза.'}
|
||||
whatHappened={isEn
|
||||
? 'The web server is not returning a proper response. This could be caused by the origin server being down or overloaded.'
|
||||
: 'Веб-сервер не возвращает корректный ответ. Это может быть вызвано недоступностью или перегрузкой сервера-источника.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try refreshing the page in a few minutes. If you are the site owner, check your server logs for errors.'
|
||||
: 'Попробуйте обновить страницу через несколько минут. Если вы владелец сайта, проверьте логи сервера на наличие ошибок.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error503: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="503"
|
||||
title={isEn ? 'Service Temporarily Unavailable' : 'Сервис временно недоступен'}
|
||||
description={isEn
|
||||
? 'The server is temporarily unable to handle the request.'
|
||||
: 'Сервер временно не может обработать запрос.'}
|
||||
whatHappened={isEn
|
||||
? 'The origin web server is currently experiencing high load or is undergoing maintenance.'
|
||||
: 'Исходный веб-сервер в настоящее время испытывает высокую нагрузку или находится на техническом обслуживании.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try refreshing the page in a few minutes. If the problem persists, please contact support.'
|
||||
: 'Попробуйте обновить страницу через несколько минут. Если проблема сохраняется, обратитесь в поддержку.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error504: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="504"
|
||||
title={isEn ? 'Gateway Timeout' : 'Превышено время ожидания'}
|
||||
description={isEn
|
||||
? 'The gateway did not receive a timely response from the upstream server.'
|
||||
: 'Шлюз не получил своевременный ответ от вышестоящего сервера.'}
|
||||
whatHappened={isEn
|
||||
? 'The origin web server did not respond within the expected time. This could be caused by slow processing or network issues.'
|
||||
: 'Исходный веб-сервер не ответил в ожидаемое время. Это может быть вызвано медленной обработкой или сетевыми проблемами.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try again later. If you are the site owner, consider increasing timeout values or optimizing your server performance.'
|
||||
: 'Попробуйте позже. Если вы владелец сайта, рассмотрите возможность увеличения таймаутов или оптимизации производительности сервера.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error520: React.FC<{ rayId?: string; ip?: string }> = ({ rayId, ip }) => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode="520"
|
||||
title={isEn ? 'Web Server Returned an Unknown Error' : 'Веб-сервер вернул неизвестную ошибку'}
|
||||
description={isEn
|
||||
? 'The origin web server returned an empty, unknown, or unexpected response.'
|
||||
: 'Исходный веб-сервер вернул пустой, неизвестный или неожиданный ответ.'}
|
||||
whatHappened={isEn
|
||||
? 'The origin web server returned an unexpected or malformed response that cannot be processed.'
|
||||
: 'Исходный веб-сервер вернул неожиданный или некорректный ответ, который не может быть обработан.'}
|
||||
whatCanIDo={isEn
|
||||
? 'If you are the site owner, check your server configuration and ensure your application is responding correctly.'
|
||||
: 'Если вы владелец сайта, проверьте конфигурацию сервера и убедитесь, что ваше приложение отвечает корректно.'}
|
||||
rayId={rayId}
|
||||
ip={ip}
|
||||
/>
|
||||
);
|
||||
};
|
||||
37
ospabhost/frontend/src/pages/errors/NetworkError.tsx
Normal file
37
ospabhost/frontend/src/pages/errors/NetworkError.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ErrorPage from '../../components/ErrorPage';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
const NetworkError: React.FC = () => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [hostname, setHostname] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setHostname(window.location.hostname);
|
||||
}, []);
|
||||
|
||||
const description = isEn
|
||||
? `You have reached a service address (${hostname || 'unknown'}). This page is not available for public access.`
|
||||
: `Вы попали на служебный адрес (${hostname || 'неизвестен'}). Эта страница недоступна для публичного доступа.`;
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
code="1000"
|
||||
title={isEn ? 'Service Address' : 'Служебный адрес'}
|
||||
description={description}
|
||||
icon={
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
}
|
||||
color="purple"
|
||||
showLoginButton={false}
|
||||
showBackButton={false}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkError;
|
||||
68
ospabhost/frontend/src/pages/errors/index.tsx
Normal file
68
ospabhost/frontend/src/pages/errors/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import CloudflareError, { Error1000, Error502, Error503, Error504, Error520 } from './CloudflareError';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
const generateRayId = () => {
|
||||
const chars = 'abcdef0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return `${result.slice(0, 8)}-${result.slice(8, 12)}-${result.slice(12)}`;
|
||||
};
|
||||
|
||||
const ErrorPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
const [rayId] = useState(() => generateRayId());
|
||||
const [userIp, setUserIp] = useState<string | undefined>();
|
||||
|
||||
const errorCode = searchParams.get('code') || '500';
|
||||
const customMessage = searchParams.get('message');
|
||||
|
||||
useEffect(() => {
|
||||
// Try to get user IP (optional)
|
||||
fetch('https://api.ipify.org?format=json')
|
||||
.then(res => res.json())
|
||||
.then(data => setUserIp(data.ip))
|
||||
.catch(() => setUserIp(undefined));
|
||||
}, []);
|
||||
|
||||
// Route to specific error pages
|
||||
switch (errorCode) {
|
||||
case '1000':
|
||||
return <Error1000 rayId={rayId} ip={userIp} />;
|
||||
case '502':
|
||||
return <Error502 rayId={rayId} ip={userIp} />;
|
||||
case '503':
|
||||
return <Error503 rayId={rayId} ip={userIp} />;
|
||||
case '504':
|
||||
return <Error504 rayId={rayId} ip={userIp} />;
|
||||
case '520':
|
||||
return <Error520 rayId={rayId} ip={userIp} />;
|
||||
default:
|
||||
// Generic error
|
||||
return (
|
||||
<CloudflareError
|
||||
errorCode={errorCode}
|
||||
title={customMessage || (isEn ? 'An Error Occurred' : 'Произошла ошибка')}
|
||||
description={isEn
|
||||
? 'If this problem persists, please contact our support team.'
|
||||
: 'Если проблема сохраняется, обратитесь в нашу службу поддержки.'}
|
||||
whatHappened={isEn
|
||||
? 'Something went wrong while processing your request. This could be a temporary issue.'
|
||||
: 'Что-то пошло не так при обработке вашего запроса. Это может быть временная проблема.'}
|
||||
whatCanIDo={isEn
|
||||
? 'Try refreshing the page or going back to the homepage. If the issue persists, please contact support.'
|
||||
: 'Попробуйте обновить страницу или вернуться на главную. Если проблема сохраняется, обратитесь в поддержку.'}
|
||||
rayId={rayId}
|
||||
ip={userIp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -1,65 +1,86 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FaCloud, FaShieldAlt, FaDatabase } from 'react-icons/fa';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const HomePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-800">
|
||||
<div className="min-h-screen bg-gray-50 text-gray-800 overflow-hidden">
|
||||
{/* Hero Section */}
|
||||
<section className="relative bg-gradient-to-b from-blue-100 to-white pt-24 pb-32">
|
||||
<div className="container mx-auto text-center px-4">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900">
|
||||
Облачное хранилище <br /> для ваших данных
|
||||
<section className="relative bg-white pt-24 pb-32 overflow-hidden">
|
||||
{/* Light gradient background */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-white to-indigo-50" />
|
||||
</div>
|
||||
|
||||
{/* Soft mesh gradient - only blue tones */}
|
||||
<div className="absolute top-0 left-0 w-[600px] h-[600px] bg-blue-400/15 rounded-full blur-[100px] -translate-x-1/4 -translate-y-1/4" />
|
||||
<div className="absolute bottom-0 right-0 w-[500px] h-[500px] bg-indigo-400/15 rounded-full blur-[100px] translate-x-1/4 translate-y-1/4" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-sky-200/30 rounded-full blur-[80px]" />
|
||||
|
||||
<div className="container mx-auto text-center px-4 relative z-10">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900 animate-fade-in-down">
|
||||
{t('home.hero.title')}
|
||||
</h1>
|
||||
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-700">
|
||||
S3-совместимое хранилище с высокой доступностью и надежностью. Храните файлы, резервные копии и медиа-контент.
|
||||
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-600 animate-fade-in-up animation-delay-200" style={{ opacity: 0 }}>
|
||||
{t('home.hero.description')}
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<div className="mt-10 flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4 animate-fade-in-up animation-delay-400" style={{ opacity: 0 }}>
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-8 py-4 rounded-full text-white font-bold text-lg transition-transform transform hover:scale-105 shadow-lg bg-ospab-primary hover:bg-ospab-accent"
|
||||
to={localePath('/register')}
|
||||
className="px-8 py-4 rounded-full text-white font-bold text-lg shadow-lg shadow-blue-500/30 bg-ospab-primary hover:bg-blue-700 btn-hover"
|
||||
>
|
||||
Начать бесплатно
|
||||
{t('home.hero.cta')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-8 py-4 rounded-full text-gray-800 font-bold text-lg border-2 border-gray-400 transition-colors hover:bg-gray-200 hover:border-gray-300"
|
||||
to={localePath('/login')}
|
||||
className="px-8 py-4 rounded-full text-gray-700 font-bold text-lg border-2 border-gray-300 hover:bg-gray-100 hover:border-gray-400 btn-hover"
|
||||
>
|
||||
Войти в аккаунт
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-4">
|
||||
<section className="py-20 px-4 bg-white">
|
||||
<div className="container mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 text-gray-900">Наши возможности</h2>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 text-gray-900 animate-fade-in-up">{t('home.features.title')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 transform hover:scale-105">
|
||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.1s' }}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<FaDatabase className="text-5xl text-blue-500" />
|
||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
||||
<FaDatabase className="text-4xl text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">S3 API</h3>
|
||||
<p className="mt-2 text-center text-gray-700">
|
||||
Полная совместимость с Amazon S3 API. Используйте привычные инструменты и SDK.
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.s3Compatible.title')}</h3>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
{t('home.features.s3Compatible.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 transform hover:scale-105">
|
||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.25s' }}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<FaCloud className="text-5xl text-blue-500" />
|
||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
||||
<FaCloud className="text-4xl text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">Масштабируемость</h3>
|
||||
<p className="mt-2 text-center text-gray-700">
|
||||
Неограниченное хранилище. Платите только за используемое пространство.
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.speed.title')}</h3>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
{t('home.features.speed.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 transform hover:scale-105">
|
||||
<div className="bg-gray-50 p-8 rounded-2xl shadow-lg card-hover animate-slide-up" style={{ opacity: 0, animationDelay: '0.4s' }}>
|
||||
<div className="flex justify-center mb-4">
|
||||
<FaShieldAlt className="text-5xl text-blue-500" />
|
||||
<div className="p-4 bg-blue-100 rounded-2xl">
|
||||
<FaShieldAlt className="text-4xl text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">Надежность и безопасность</h3>
|
||||
<p className="mt-2 text-center text-gray-700">
|
||||
Шифрование данных, резервное копирование и высокая доступность 99.9%.
|
||||
<h3 className="text-2xl font-bold text-center text-gray-900">{t('home.features.reliability.title')}</h3>
|
||||
<p className="mt-2 text-center text-gray-600">
|
||||
{t('home.features.reliability.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,20 +88,29 @@ const HomePage = () => {
|
||||
</section>
|
||||
|
||||
{/* Call to Action Section */}
|
||||
<section className="bg-gray-800 py-20 px-4 text-white text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold leading-tight">
|
||||
Готовы начать?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg md:text-xl max-w-2xl mx-auto text-gray-400">
|
||||
Присоединяйтесь к разработчикам, которые доверяют нам свои данные.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-8 py-4 rounded-full text-white font-bold text-lg transition-transform transform hover:scale-105 shadow-lg bg-ospab-primary hover:bg-ospab-accent"
|
||||
>
|
||||
Начать бесплатно
|
||||
</Link>
|
||||
<section className="bg-gradient-to-r from-gray-900 to-gray-800 py-20 px-4 text-white text-center relative overflow-hidden">
|
||||
{/* Subtle pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23ffffff' fill-opacity='1' fill-rule='evenodd'%3E%3Ccircle cx='3' cy='3' r='3'/%3E%3Ccircle cx='13' cy='13' r='3'/%3E%3C/g%3E%3C/svg%3E")`
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl md:text-5xl font-bold leading-tight animate-fade-in-up">
|
||||
{t('home.cta.title')}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg md:text-xl max-w-2xl mx-auto text-gray-400 animate-fade-in-up animation-delay-200" style={{ opacity: 0 }}>
|
||||
{t('home.cta.description')}
|
||||
</p>
|
||||
<div className="mt-8 animate-fade-in-up animation-delay-400" style={{ opacity: 0 }}>
|
||||
<Link
|
||||
to={localePath('/register')}
|
||||
className="inline-block px-8 py-4 rounded-full text-white font-bold text-lg shadow-lg bg-blue-600 hover:bg-blue-700 btn-hover"
|
||||
>
|
||||
{t('home.hero.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import { API_URL } from '../config/api';
|
||||
import QRLogin from '../components/QRLogin';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [loginMethod, setLoginMethod] = useState<'password' | 'qr'>('password');
|
||||
@@ -18,36 +20,48 @@ const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login, isLoggedIn } = useAuth();
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||
|
||||
// Если уже авторизован — редирект на dashboard
|
||||
// Redirect if logged in
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
// Обработка OAuth токена из URL
|
||||
// Handle OAuth token from URL & QR param
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const authError = params.get('error');
|
||||
const qrParam = params.get('qr');
|
||||
|
||||
if (token) {
|
||||
login(token);
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
setError('Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
}, [isLoggedIn, navigate, location, login]);
|
||||
|
||||
if (qrParam === '1' || qrParam === 'true') {
|
||||
setLoginMethod('qr');
|
||||
// allow QR component to generate immediately
|
||||
}
|
||||
}, [isLoggedIn, navigate, location, login, localePath, locale]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!turnstileToken) {
|
||||
setError('Пожалуйста, подтвердите, что вы не робот.');
|
||||
setError(locale === 'en'
|
||||
? 'Please confirm you are not a robot.'
|
||||
: 'Пожалуйста, подтвердите, что вы не робот.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,22 +73,24 @@ const LoginPage = () => {
|
||||
turnstileToken: turnstileToken,
|
||||
});
|
||||
login(response.data.token);
|
||||
// Возврат на исходную страницу, если был редирект
|
||||
// Return to original page if redirected
|
||||
type LocationState = { from?: { pathname?: string } };
|
||||
const state = location.state as LocationState | null;
|
||||
const from = state?.from?.pathname || '/dashboard';
|
||||
const from = state?.from?.pathname || localePath('/dashboard');
|
||||
navigate(from);
|
||||
} catch (err) {
|
||||
// Сброс капчи при ошибке
|
||||
// Reset captcha on error
|
||||
if (turnstileRef.current) {
|
||||
turnstileRef.current.reset();
|
||||
}
|
||||
setTurnstileToken(null);
|
||||
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
setError(err.response.data.message || 'Неизвестная ошибка входа.');
|
||||
setError(err.response.data.message || (locale === 'en' ? 'Unknown login error.' : 'Неизвестная ошибка входа.'));
|
||||
} else {
|
||||
setError('Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
setError(locale === 'en'
|
||||
? 'Network error. Please try again later.'
|
||||
: 'Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -88,9 +104,9 @@ const LoginPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">Вход в аккаунт</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.login.title')}</h1>
|
||||
|
||||
{/* Переключатель метода входа */}
|
||||
{/* Login method toggle */}
|
||||
<div className="flex mb-6 bg-gray-100 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,7 +117,7 @@ const LoginPage = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Пароль
|
||||
{t('auth.login.password')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,7 +128,7 @@ const LoginPage = () => {
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
QR-код
|
||||
{locale === 'en' ? 'QR Code' : 'QR-код'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +139,7 @@ const LoginPage = () => {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Электронная почта"
|
||||
placeholder={t('auth.login.email')}
|
||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -132,7 +148,7 @@ const LoginPage = () => {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Пароль"
|
||||
placeholder={t('auth.login.password')}
|
||||
className="w-full px-5 py-3 mb-6 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -146,7 +162,9 @@ const LoginPage = () => {
|
||||
onSuccess={(token: string) => setTurnstileToken(token)}
|
||||
onError={() => {
|
||||
setTurnstileToken(null);
|
||||
setError('Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||
setError(locale === 'en'
|
||||
? 'Captcha loading error. Try refreshing the page.'
|
||||
: 'Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||
}}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
@@ -157,7 +175,9 @@ const LoginPage = () => {
|
||||
disabled={isLoading || !turnstileToken}
|
||||
className="w-full px-5 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Входим...' : 'Войти'}
|
||||
{isLoading
|
||||
? (locale === 'en' ? 'Signing in...' : 'Входим...')
|
||||
: t('auth.login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
@@ -167,17 +187,17 @@ const LoginPage = () => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<QRLogin onSuccess={() => navigate('/dashboard')} />
|
||||
<QRLogin onSuccess={() => navigate(localePath('/dashboard'))} />
|
||||
)}
|
||||
|
||||
{/* Социальные сети */}
|
||||
{/* Social login */}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Или войти через</span>
|
||||
<span className="px-2 bg-white text-gray-500">{t('auth.login.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,7 +206,7 @@ const LoginPage = () => {
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label="Войти через Google"
|
||||
aria-label={locale === 'en' ? 'Sign in with Google' : 'Войти через Google'}
|
||||
>
|
||||
<img src="/google.png" alt="Google" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -195,7 +215,7 @@ const LoginPage = () => {
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label="Войти через GitHub"
|
||||
aria-label={locale === 'en' ? 'Sign in with GitHub' : 'Войти через GitHub'}
|
||||
>
|
||||
<img src="/github.png" alt="GitHub" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -204,7 +224,7 @@ const LoginPage = () => {
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('yandex')}
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label="Войти через Yandex"
|
||||
aria-label={locale === 'en' ? 'Sign in with Yandex' : 'Войти через Yandex'}
|
||||
>
|
||||
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
||||
</button>
|
||||
@@ -213,9 +233,9 @@ const LoginPage = () => {
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-gray-600">
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/register" className="text-ospab-primary font-bold hover:underline">
|
||||
Зарегистрироваться
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link to={localePath('/register')} className="text-ospab-primary font-bold hover:underline">
|
||||
{t('auth.register.title')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React from 'react';
|
||||
import PageTmpl from '../components/pagetempl';
|
||||
import { useTranslation } from '../i18n';
|
||||
|
||||
const Privacy: React.FC = () => {
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
|
||||
return (
|
||||
<PageTmpl>
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">Политика конфиденциальности ospab.host</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||
{isEn ? 'Privacy Policy ospab.host' : 'Политика конфиденциальности ospab.host'}
|
||||
</h1>
|
||||
|
||||
<div className="prose prose-lg max-w-none">
|
||||
<p className="text-gray-600 mb-6">
|
||||
|
||||
@@ -15,6 +15,8 @@ const QRLoginPage = () => {
|
||||
const [status, setStatus] = useState<'loading' | 'confirm' | 'success' | 'error' | 'expired'>('loading');
|
||||
const [message, setMessage] = useState('Проверка QR-кода...');
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
const [remaining, setRemaining] = useState<number>(0);
|
||||
const [requestInfo, setRequestInfo] = useState<{ ip?: string; ua?: string } | null>(null);
|
||||
const code = searchParams.get('code');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,6 +26,30 @@ const QRLoginPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const updateRemaining = async () => {
|
||||
try {
|
||||
const resp = await apiClient.get(`/api/qr-auth/status/${code}`);
|
||||
if (typeof resp.data.expiresIn === 'number') {
|
||||
setRemaining(Math.max(0, Math.ceil(resp.data.expiresIn)));
|
||||
}
|
||||
// Set request info if provided
|
||||
if (resp.data.ipAddress || resp.data.userAgent) {
|
||||
setRequestInfo({ ip: resp.data.ipAddress, ua: resp.data.userAgent });
|
||||
}
|
||||
if (resp.data.status === 'expired') {
|
||||
setStatus('expired');
|
||||
setMessage('QR-код истёк');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении remaining:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
setStatus('loading');
|
||||
@@ -50,12 +76,31 @@ const QRLoginPage = () => {
|
||||
setUserData(userResponse.data.user);
|
||||
setStatus('confirm');
|
||||
setMessage('Подтвердите вход на новом устройстве');
|
||||
|
||||
// Start countdown for confirmation page
|
||||
await updateRemaining();
|
||||
countdownTimer = setInterval(async () => {
|
||||
await updateRemaining();
|
||||
// decrease visible counter only if updateRemaining didn't set a new value
|
||||
setRemaining((prev: number) => {
|
||||
if (prev <= 1) {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
setStatus('expired');
|
||||
setMessage('QR-код истёк');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки авторизации:', error);
|
||||
|
||||
if (isAxiosError(error) && error.response?.status === 401) {
|
||||
setStatus('error');
|
||||
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
|
||||
} else if (isAxiosError(error) && error.response?.status === 500) {
|
||||
setStatus('error');
|
||||
setMessage('Серверная ошибка при проверке QR-кода');
|
||||
} else {
|
||||
setStatus('error');
|
||||
setMessage('Ошибка проверки авторизации');
|
||||
@@ -64,6 +109,10 @@ const QRLoginPage = () => {
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
return () => {
|
||||
if (countdownTimer) clearInterval(countdownTimer);
|
||||
};
|
||||
}, [code]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
@@ -102,10 +151,6 @@ const QRLoginPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
@@ -161,30 +206,61 @@ const QRLoginPage = () => {
|
||||
|
||||
{status === 'confirm' && userData && (
|
||||
<div className="mb-6">
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-4">
|
||||
<p className="text-gray-600 mb-2">Войти на новом устройстве как:</p>
|
||||
<p className="text-xl font-bold text-gray-900">{userData.username}</p>
|
||||
<p className="text-sm text-gray-500">{userData.email}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
Это вы пытаетесь войти? Подтвердите вход на компьютере
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
{/* Device info */}
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
<div className="mb-2">Детали запроса:</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-gray-100 text-xs text-gray-700">
|
||||
<div>IP: <span className="font-medium">{requestInfo?.ip ?? '—'}</span></div>
|
||||
<div className="mt-1">Device: <span className="font-medium">{requestInfo?.ua ?? '—'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-friendly confirmation with timer */}
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-600 text-sm mb-3">{remaining > 0 ? `Осталось времени: ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}` : 'QR-код истёк'}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiClient.post('/api/qr-auth/reject', { code });
|
||||
setStatus('error');
|
||||
setMessage('Вход отклонён');
|
||||
} catch (err) {
|
||||
console.error('Ошибка отклонения:', err);
|
||||
setMessage('Не удалось отклонить вход');
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-4 rounded-lg font-medium transition-colors duration-200 text-sm"
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={remaining <= 0}
|
||||
className={`flex-1 ${remaining > 0 ? 'bg-blue-500 hover:bg-blue-600 text-white' : 'bg-gray-200 text-gray-500 cursor-not-allowed'} px-6 py-4 rounded-lg font-medium transition-colors duration-200 text-sm`}
|
||||
>
|
||||
Подтвердить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remaining <= 0 && (
|
||||
<div className="mt-3 text-center">
|
||||
<button
|
||||
onClick={() => window.open('/login?qr=1', '_blank')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Сгенерировать QR заново (открыть страницу входа)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
@@ -6,11 +6,16 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import useAuth from '../context/useAuth';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useToast } from '../hooks/useToast';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
import { validateEmail } from '../utils/emailValidation';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const { addToast } = useToast();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -19,10 +24,36 @@ const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||
|
||||
// Обработка OAuth токена из URL
|
||||
// Email validation on blur
|
||||
const handleEmailBlur = useCallback(() => {
|
||||
if (!email.trim()) {
|
||||
setEmailError(null);
|
||||
setEmailSuggestion(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = validateEmail(email, locale);
|
||||
setEmailError(result.isValid ? null : result.error ?? null);
|
||||
setEmailSuggestion(result.suggestion ?? null);
|
||||
}, [email, locale]);
|
||||
|
||||
// Apply email suggestion
|
||||
const applySuggestion = useCallback(() => {
|
||||
if (emailSuggestion) {
|
||||
const match = emailSuggestion.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/);
|
||||
if (match) {
|
||||
setEmail(match[1]);
|
||||
setEmailSuggestion(null);
|
||||
}
|
||||
}
|
||||
}, [emailSuggestion]);
|
||||
|
||||
// Handle OAuth token from URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
@@ -30,48 +61,57 @@ const RegisterPage = () => {
|
||||
|
||||
if (token) {
|
||||
login(token);
|
||||
navigate('/dashboard', { replace: true });
|
||||
navigate(localePath('/dashboard'), { replace: true });
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
setError('Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
setError(locale === 'en'
|
||||
? 'Social login error. Please try again.'
|
||||
: 'Ошибка авторизации через социальную сеть. Попробуйте снова.');
|
||||
}
|
||||
}, [location, login, navigate]);
|
||||
}, [location, login, navigate, localePath, locale]);
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(''); // Очищаем предыдущие ошибки
|
||||
setError('');
|
||||
|
||||
// Validate email before submit
|
||||
const emailValidation = validateEmail(email, locale);
|
||||
if (!emailValidation.isValid) {
|
||||
setEmailError(emailValidation.error ?? t('auth.register.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!turnstileToken) {
|
||||
setError('Пожалуйста, подтвердите, что вы не робот.');
|
||||
setError(t('auth.register.captchaRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/api/auth/register`, {
|
||||
await axios.post(`${API_URL}/api/auth/register`, {
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
turnstileToken: turnstileToken,
|
||||
});
|
||||
|
||||
addToast('Регистрация прошла успешно! Теперь вы можете войти.', 'success');
|
||||
navigate('/login');
|
||||
addToast(t('auth.register.success'), 'success');
|
||||
navigate(localePath('/login'));
|
||||
|
||||
} catch (err) {
|
||||
// Сброс капчи при ошибке
|
||||
// Reset captcha on error
|
||||
if (turnstileRef.current) {
|
||||
turnstileRef.current.reset();
|
||||
}
|
||||
setTurnstileToken(null);
|
||||
|
||||
if (axios.isAxiosError(err) && err.response) {
|
||||
const errorMsg = err.response.data.message || 'Неизвестная ошибка регистрации.';
|
||||
const errorMsg = err.response.data.message || t('auth.register.unknownError');
|
||||
setError(errorMsg);
|
||||
} else {
|
||||
setError('Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||
setError(t('auth.register.networkError'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -85,27 +125,50 @@ const RegisterPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">Регистрация</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.register.title')}</h1>
|
||||
<form onSubmit={handleRegister}>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Имя пользователя"
|
||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Электронная почта"
|
||||
placeholder={t('auth.register.usernamePlaceholder')}
|
||||
className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
/>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setEmailError(null);
|
||||
setEmailSuggestion(null);
|
||||
}}
|
||||
onBlur={handleEmailBlur}
|
||||
placeholder={t('auth.register.emailPlaceholder')}
|
||||
className={`w-full px-5 py-3 border rounded-full focus:outline-none focus:ring-2 ${
|
||||
emailError
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:ring-ospab-primary'
|
||||
}`}
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="mt-1 text-sm text-red-500 text-left px-3">{emailError}</p>
|
||||
)}
|
||||
{emailSuggestion && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySuggestion}
|
||||
className="mt-1 text-sm text-blue-600 hover:text-blue-800 text-left px-3 cursor-pointer"
|
||||
>
|
||||
{emailSuggestion}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Пароль"
|
||||
placeholder={t('auth.register.passwordPlaceholder')}
|
||||
className="w-full px-5 py-3 mb-6 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary"
|
||||
required
|
||||
disabled={isLoading}
|
||||
@@ -119,7 +182,7 @@ const RegisterPage = () => {
|
||||
onSuccess={(token: string) => setTurnstileToken(token)}
|
||||
onError={() => {
|
||||
setTurnstileToken(null);
|
||||
setError('Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||
setError(t('auth.register.captchaError'));
|
||||
}}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
@@ -127,24 +190,24 @@ const RegisterPage = () => {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !turnstileToken}
|
||||
disabled={isLoading || !turnstileToken || !!emailError}
|
||||
className="w-full px-5 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Регистрируем...' : 'Зарегистрироваться'}
|
||||
{isLoading ? t('auth.register.loading') : t('auth.register.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Социальные сети */}
|
||||
{/* Social networks */}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Или зарегистрироваться через</span>
|
||||
<span className="px-2 bg-white text-gray-500">{t('auth.register.orRegisterWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,9 +248,9 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-gray-600">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to="/login" className="text-ospab-primary font-bold hover:underline">
|
||||
Войти
|
||||
{t('auth.register.haveAccount')}{' '}
|
||||
<Link to={localePath('/login')} className="text-ospab-primary font-bold hover:underline">
|
||||
{t('auth.register.loginLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from 'react-icons/fa';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { API_URL } from '../config/api';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { useLocalePath } from '../middleware';
|
||||
|
||||
type StoragePlanDto = {
|
||||
id: number;
|
||||
@@ -35,23 +37,33 @@ type DecoratedPlan = StoragePlanDto & {
|
||||
};
|
||||
|
||||
const TIER_LABELS = ['Developer', 'Team', 'Scale', 'Enterprise'];
|
||||
const BASE_FEATURES = [
|
||||
'S3-совместимый API и совместимость с AWS SDK',
|
||||
'Развёртывание в регионе ru-central-1',
|
||||
'Версионирование и presigned URL',
|
||||
'Управление доступом через Access Key/Secret Key',
|
||||
'Уведомления и мониторинг в панели клиента'
|
||||
];
|
||||
|
||||
const formatMetric = (value: number, suffix: string) => `${value.toLocaleString('ru-RU')} ${suffix}`;
|
||||
|
||||
const S3PlansPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, locale } = useTranslation();
|
||||
const localePath = useLocalePath();
|
||||
const [plans, setPlans] = useState<StoragePlanDto[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectingPlan, setSelectingPlan] = useState<string | null>(null);
|
||||
|
||||
const BASE_FEATURES = locale === 'en' ? [
|
||||
'S3-compatible API and AWS SDK support',
|
||||
'Deployment in ru-central-1 region',
|
||||
'Versioning and presigned URLs',
|
||||
'Access management via Access Key/Secret Key',
|
||||
'Notifications and monitoring in client panel'
|
||||
] : [
|
||||
'S3-совместимый API и совместимость с AWS SDK',
|
||||
'Развёртывание в регионе ru-central-1',
|
||||
'Версионирование и presigned URL',
|
||||
'Управление доступом через Access Key/Secret Key',
|
||||
'Уведомления и мониторинг в панели клиента'
|
||||
];
|
||||
|
||||
const formatMetric = (value: number, suffix: string) =>
|
||||
`${value.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} ${suffix}`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -61,14 +73,14 @@ const S3PlansPage = () => {
|
||||
setError(null);
|
||||
const response = await fetch(`${API_URL}/api/storage/plans`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить тарифы');
|
||||
throw new Error(locale === 'en' ? 'Failed to load plans' : 'Не удалось загрузить тарифы');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!cancelled) {
|
||||
setPlans(Array.isArray(data?.plans) ? data.plans : []);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка загрузки тарифов';
|
||||
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Error loading plans' : 'Ошибка загрузки тарифов');
|
||||
if (!cancelled) {
|
||||
setError(message);
|
||||
}
|
||||
@@ -150,9 +162,9 @@ const S3PlansPage = () => {
|
||||
if (!cartId) {
|
||||
throw new Error('Ответ сервера без идентификатора корзины');
|
||||
}
|
||||
navigate(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`);
|
||||
navigate(localePath(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось начать оплату';
|
||||
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Failed to start payment' : 'Не удалось начать оплату');
|
||||
setError(message);
|
||||
} finally {
|
||||
setSelectingPlan(null);
|
||||
@@ -168,21 +180,22 @@ const S3PlansPage = () => {
|
||||
<span>S3 Object Storage</span>
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-6 text-gray-900">
|
||||
Прозрачные тарифы для любого объёма
|
||||
{locale === 'en' ? 'Transparent pricing for any volume' : 'Прозрачные тарифы для любого объёма'}
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-600 max-w-3xl mx-auto mb-8">
|
||||
Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера,
|
||||
с включённым трафиком, запросами и приоритетной поддержкой.
|
||||
{locale === 'en'
|
||||
? 'Pay only for the resources you need. 12 ready-made plans for teams of any size, with included traffic, requests and priority support.'
|
||||
: 'Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера, с включённым трафиком, запросами и приоритетной поддержкой.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
||||
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s сеть
|
||||
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s {locale === 'en' ? 'network' : 'сеть'}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
||||
<FaLock className="text-emerald-500" /> AES-256 at-rest
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
||||
<FaInfinity className="text-purple-500" /> S3-совместимый API
|
||||
<FaInfinity className="text-purple-500" /> {locale === 'en' ? 'S3-compatible API' : 'S3-совместимый API'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,27 +208,33 @@ const S3PlansPage = () => {
|
||||
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center mb-4">
|
||||
<FaBolt className="text-2xl text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Готовность к нагрузке</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'Load Ready' : 'Готовность к нагрузке'}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Единая платформа на NVMe с автоматическим масштабированием, CDN-интеграцией и кросс-региональной репликацией.
|
||||
{locale === 'en'
|
||||
? 'Unified NVMe platform with auto-scaling, CDN integration and cross-region replication.'
|
||||
: 'Единая платформа на NVMe с автоматическим масштабированием, CDN-интеграцией и кросс-региональной репликацией.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<div className="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<FaShieldAlt className="text-2xl text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Безопасность по умолчанию</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'Security by Default' : 'Безопасность по умолчанию'}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
3 копии данных, IAM роли, шифрование in-transit и at-rest, audit логи, Object Lock и политики хранения.
|
||||
{locale === 'en'
|
||||
? '3 data copies, IAM roles, in-transit and at-rest encryption, audit logs, Object Lock and retention policies.'
|
||||
: '3 копии данных, IAM роли, шифрование in-transit и at-rest, audit логи, Object Lock и политики хранения.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<FaCloud className="text-2xl text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Совместимость с AWS SDK</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{locale === 'en' ? 'AWS SDK Compatibility' : 'Совместимость с AWS SDK'}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Полный S3 API, поддержка AWS CLI, Terraform, rclone, s3cmd и других инструментов без изменений в коде.
|
||||
{locale === 'en'
|
||||
? 'Full S3 API, support for AWS CLI, Terraform, rclone, s3cmd and other tools without code changes.'
|
||||
: 'Полный S3 API, поддержка AWS CLI, Terraform, rclone, s3cmd и других инструментов без изменений в коде.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +261,7 @@ const S3PlansPage = () => {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">{section.label} Tier</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Подберите план по объёму хранилища и включённому трафику
|
||||
{locale === 'en' ? 'Choose a plan by storage volume and included traffic' : 'Подберите план по объёму хранилища и включённому трафику'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -263,21 +282,21 @@ const S3PlansPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-4xl font-bold text-gray-900">₽{plan.price.toLocaleString('ru-RU')}</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">в месяц</span>
|
||||
<span className="text-4xl font-bold text-gray-900">₽{plan.price.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">{locale === 'en' ? 'per month' : 'в месяц'}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm mb-6">
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Хранилище</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Storage' : 'Хранилище'}</span>
|
||||
<span className="font-semibold text-gray-900">{formatMetric(plan.quotaGb, 'GB')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Исходящий трафик</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Outbound traffic' : 'Исходящий трафик'}</span>
|
||||
<span className="font-semibold text-gray-900">{formatMetric(plan.bandwidthGb, 'GB')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Запросы</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Requests' : 'Запросы'}</span>
|
||||
<span className="font-semibold text-gray-900">{plan.requestLimit}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,11 +325,11 @@ const S3PlansPage = () => {
|
||||
{selectingPlan === plan.code ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Создание корзины...</span>
|
||||
<span>{locale === 'en' ? 'Creating cart...' : 'Создание корзины...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Выбрать план</span>
|
||||
<span>{locale === 'en' ? 'Select plan' : 'Выбрать план'}</span>
|
||||
<FaArrowRight />
|
||||
</>
|
||||
)}
|
||||
@@ -326,8 +345,8 @@ const S3PlansPage = () => {
|
||||
{customPlan && customPlanCalculated && (
|
||||
<div className="mt-20 pt-20 border-t border-gray-200">
|
||||
<div className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Кастомный тариф</h2>
|
||||
<p className="text-gray-600">Укажите нужное количество GB и получите автоматический расчёт стоимости</p>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">{locale === 'en' ? 'Custom Plan' : 'Кастомный тариф'}</h2>
|
||||
<p className="text-gray-600">{locale === 'en' ? 'Specify the required amount of GB and get automatic cost calculation' : 'Укажите нужное количество GB и получите автоматический расчёт стоимости'}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-8 border border-blue-200">
|
||||
@@ -335,7 +354,7 @@ const S3PlansPage = () => {
|
||||
{/* Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 mb-4">
|
||||
Сколько GB вам нужно?
|
||||
{locale === 'en' ? 'How many GB do you need?' : 'Сколько GB вам нужно?'}
|
||||
</label>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<input
|
||||
@@ -359,7 +378,7 @@ const S3PlansPage = () => {
|
||||
<span className="text-lg font-semibold text-gray-900 min-w-fit">GB</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
До {maxStorageGb.toLocaleString('ru-RU')} GB
|
||||
{locale === 'en' ? `Up to ${maxStorageGb.toLocaleString('en-US')} GB` : `До ${maxStorageGb.toLocaleString('ru-RU')} GB`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -373,24 +392,24 @@ const S3PlansPage = () => {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
<span className="text-gray-500 ml-2 text-sm">в месяц</span>
|
||||
<span className="text-gray-500 ml-2 text-sm">{locale === 'en' ? 'per month' : 'в месяц'}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm mb-6">
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Хранилище</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Storage' : 'Хранилище'}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{customPlanCalculated.quotaGb.toLocaleString('ru-RU')} GB
|
||||
{customPlanCalculated.quotaGb.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Исходящий трафик</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Outbound traffic' : 'Исходящий трафик'}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{customPlanCalculated.bandwidthGb.toLocaleString('ru-RU')} GB
|
||||
{customPlanCalculated.bandwidthGb.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className="text-gray-500">Запросы</span>
|
||||
<span className="text-gray-500">{locale === 'en' ? 'Requests' : 'Запросы'}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{customPlanCalculated.requestLimit}
|
||||
</span>
|
||||
@@ -410,11 +429,11 @@ const S3PlansPage = () => {
|
||||
{selectingPlan === customPlan.code ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Создание корзины...</span>
|
||||
<span>{locale === 'en' ? 'Creating cart...' : 'Создание корзины...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Выбрать кастомный план</span>
|
||||
<span>{locale === 'en' ? 'Select custom plan' : 'Выбрать кастомный план'}</span>
|
||||
<FaArrowRight />
|
||||
</>
|
||||
)}
|
||||
@@ -427,13 +446,13 @@ const S3PlansPage = () => {
|
||||
|
||||
<div className="mt-20 text-center">
|
||||
<p className="text-gray-600 mb-4">
|
||||
Нужна гибридная архитектура или больше 5 PB хранения?
|
||||
{locale === 'en' ? 'Need hybrid architecture or more than 5 PB storage?' : 'Нужна гибридная архитектура или больше 5 PB хранения?'}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:sales@ospab.host"
|
||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Связаться с командой продаж
|
||||
{locale === 'en' ? 'Contact sales team' : 'Связаться с командой продаж'}
|
||||
<FaArrowRight />
|
||||
</a>
|
||||
</div>
|
||||
@@ -443,25 +462,31 @@ const S3PlansPage = () => {
|
||||
<section className="py-20 px-6 sm:px-8 bg-white">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
|
||||
Подходит для любых сценариев
|
||||
{locale === 'en' ? 'Suitable for any scenario' : 'Подходит для любых сценариев'}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-3">Бэкапы и DR</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">{locale === 'en' ? 'Backups & DR' : 'Бэкапы и DR'}</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.
|
||||
{locale === 'en'
|
||||
? 'Replication, Object Lock and object lifecycle allow storing backups and quick recovery.'
|
||||
: 'Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-3">Медиа-платформы</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">{locale === 'en' ? 'Media Platforms' : 'Медиа-платформы'}</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.
|
||||
{locale === 'en'
|
||||
? 'CDN integration, presigned URLs and high bandwidth for video, images and audio.'
|
||||
: 'CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gray-50 rounded-xl">
|
||||
<h3 className="text-lg font-semibold mb-3">SaaS & Data Lake</h3>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.
|
||||
{locale === 'en'
|
||||
? 'IAM, API versions and audit logs ensure security and compliance with GDPR, 152-FZ and SOC 2.'
|
||||
: 'IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,22 +495,24 @@ const S3PlansPage = () => {
|
||||
|
||||
<section className="py-20 px-6 sm:px-8 bg-gray-900 text-white">
|
||||
<div className="container mx-auto max-w-4xl text-center">
|
||||
<h2 className="text-4xl font-bold mb-6">Готовы развернуть S3 хранилище?</h2>
|
||||
<h2 className="text-4xl font-bold mb-6">{locale === 'en' ? 'Ready to deploy S3 storage?' : 'Готовы развернуть S3 хранилище?'}</h2>
|
||||
<p className="text-lg sm:text-xl mb-8 text-white/80">
|
||||
Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.
|
||||
{locale === 'en'
|
||||
? 'Create an account and get access to management console, API keys and detailed usage analytics.'
|
||||
: 'Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.'}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Link
|
||||
to="/register"
|
||||
to={localePath('/register')}
|
||||
className="px-8 py-4 bg-white text-blue-600 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Зарегистрироваться
|
||||
{t('nav.register')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
to={localePath('/login')}
|
||||
className="px-8 py-4 bg-blue-500 text-white rounded-lg font-semibold hover:bg-blue-400 transition-colors"
|
||||
>
|
||||
Войти
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
171
ospabhost/frontend/src/utils/emailValidation.ts
Normal file
171
ospabhost/frontend/src/utils/emailValidation.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Email validation service
|
||||
* Uses multiple validation methods for reliable email checking
|
||||
*/
|
||||
|
||||
// Regex for basic email format validation
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
// Common disposable email domains (partial list)
|
||||
const DISPOSABLE_DOMAINS = new Set([
|
||||
'10minutemail.com',
|
||||
'tempmail.com',
|
||||
'throwaway.email',
|
||||
'guerrillamail.com',
|
||||
'mailinator.com',
|
||||
'temp-mail.org',
|
||||
'fakeinbox.com',
|
||||
'trashmail.com',
|
||||
'maildrop.cc',
|
||||
'yopmail.com',
|
||||
'sharklasers.com',
|
||||
'grr.la',
|
||||
'getairmail.com',
|
||||
'dispostable.com',
|
||||
]);
|
||||
|
||||
// Common typos in email domains
|
||||
const DOMAIN_TYPOS: Record<string, string> = {
|
||||
'gmial.com': 'gmail.com',
|
||||
'gmal.com': 'gmail.com',
|
||||
'gmil.com': 'gmail.com',
|
||||
'gmail.co': 'gmail.com',
|
||||
'gamil.com': 'gmail.com',
|
||||
'gmaill.com': 'gmail.com',
|
||||
'gnail.com': 'gmail.com',
|
||||
'yahooo.com': 'yahoo.com',
|
||||
'yaho.com': 'yahoo.com',
|
||||
'hotmal.com': 'hotmail.com',
|
||||
'hotmai.com': 'hotmail.com',
|
||||
'hotmial.com': 'hotmail.com',
|
||||
'outlok.com': 'outlook.com',
|
||||
'outloo.com': 'outlook.com',
|
||||
'yandx.ru': 'yandex.ru',
|
||||
'yanex.ru': 'yandex.ru',
|
||||
'mail.r': 'mail.ru',
|
||||
'mal.ru': 'mail.ru',
|
||||
};
|
||||
|
||||
export type EmailValidationResult = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
suggestion?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Basic email format validation
|
||||
*/
|
||||
export function validateEmailFormat(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email domain is disposable
|
||||
*/
|
||||
export function isDisposableEmail(email: string): boolean {
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
return domain ? DISPOSABLE_DOMAINS.has(domain) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for common typos and suggest corrections
|
||||
*/
|
||||
export function suggestEmailCorrection(email: string): string | null {
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const domain = parts[1].toLowerCase();
|
||||
const suggestion = DOMAIN_TYPOS[domain];
|
||||
|
||||
if (suggestion) {
|
||||
return `${parts[0]}@${suggestion}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email with all checks
|
||||
*/
|
||||
export function validateEmail(email: string, locale: 'ru' | 'en' = 'ru'): EmailValidationResult {
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
|
||||
// Check if empty
|
||||
if (!trimmedEmail) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: locale === 'en' ? 'Email is required' : 'Email обязателен',
|
||||
};
|
||||
}
|
||||
|
||||
// Check format
|
||||
if (!validateEmailFormat(trimmedEmail)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: locale === 'en' ? 'Invalid email format' : 'Неверный формат email',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for disposable emails
|
||||
if (isDisposableEmail(trimmedEmail)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: locale === 'en'
|
||||
? 'Disposable email addresses are not allowed'
|
||||
: 'Одноразовые email-адреса не допускаются',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for typos and suggest
|
||||
const suggestion = suggestEmailCorrection(trimmedEmail);
|
||||
if (suggestion) {
|
||||
return {
|
||||
isValid: true,
|
||||
suggestion: locale === 'en'
|
||||
? `Did you mean ${suggestion}?`
|
||||
: `Возможно, вы имели в виду ${suggestion}?`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Async email validation with API check (optional)
|
||||
* Uses abstract API for deep validation if API key is provided
|
||||
*/
|
||||
export async function validateEmailAsync(
|
||||
email: string,
|
||||
locale: 'ru' | 'en' = 'ru'
|
||||
): Promise<EmailValidationResult> {
|
||||
// First do basic validation
|
||||
const basicResult = validateEmail(email, locale);
|
||||
if (!basicResult.isValid) {
|
||||
return basicResult;
|
||||
}
|
||||
|
||||
// Optional: Use Abstract API for deeper validation
|
||||
// const apiKey = import.meta.env.VITE_ABSTRACT_EMAIL_API_KEY;
|
||||
// if (apiKey) {
|
||||
// try {
|
||||
// const response = await fetch(
|
||||
// `https://emailvalidation.abstractapi.com/v1/?api_key=${apiKey}&email=${encodeURIComponent(email)}`
|
||||
// );
|
||||
// const data = await response.json();
|
||||
//
|
||||
// if (!data.deliverability || data.deliverability === 'UNDELIVERABLE') {
|
||||
// return {
|
||||
// isValid: false,
|
||||
// error: locale === 'en'
|
||||
// ? 'This email address appears to be invalid'
|
||||
// : 'Этот email-адрес недействителен',
|
||||
// };
|
||||
// }
|
||||
// } catch (err) {
|
||||
// // If API fails, continue with basic validation result
|
||||
// console.warn('Email API validation failed:', err);
|
||||
// }
|
||||
// }
|
||||
|
||||
return basicResult;
|
||||
}
|
||||
228
ospabhost/nginx.conf
Normal file
228
ospabhost/nginx.conf
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user