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
|
// VPS/Server models removed - moving to S3 storage
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String
|
username String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
// plans Plan[] @relation("UserPlans")
|
// plans Plan[] @relation("UserPlans")
|
||||||
operator Int @default(0)
|
operator Int @default(0)
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
tickets Ticket[] @relation("UserTickets")
|
tickets Ticket[] @relation("UserTickets")
|
||||||
responses Response[] @relation("OperatorResponses")
|
responses Response[] @relation("OperatorResponses")
|
||||||
checks Check[] @relation("UserChecks")
|
checks Check[] @relation("UserChecks")
|
||||||
balance Float @default(0)
|
balance Float @default(0)
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
transactions Transaction[] // История всех транзакций
|
transactions Transaction[] // История всех транзакций
|
||||||
posts Post[] @relation("PostAuthor") // Статьи блога
|
posts Post[] @relation("PostAuthor") // Статьи блога
|
||||||
comments Comment[] @relation("UserComments") // Комментарии
|
comments Comment[] @relation("UserComments") // Комментарии
|
||||||
buckets StorageBucket[] // S3 хранилища пользователя
|
buckets StorageBucket[] // S3 хранилища пользователя
|
||||||
checkoutSessions StorageCheckoutSession[]
|
checkoutSessions StorageCheckoutSession[]
|
||||||
|
// Список промокодов, использованных пользователем
|
||||||
|
usedPromoCodes PromoCode[]
|
||||||
|
|
||||||
// Новые relations для расширенных настроек
|
// Новые relations для расширенных настроек
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
loginHistory LoginHistory[]
|
loginHistory LoginHistory[]
|
||||||
apiKeys APIKey[]
|
apiKeys APIKey[]
|
||||||
notificationSettings NotificationSettings?
|
notificationSettings NotificationSettings?
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
qrLoginRequests QrLoginRequest[]
|
qrLoginRequests QrLoginRequest[]
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@@ -53,48 +55,47 @@ model Check {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation("UserChecks", fields: [userId], references: [id])
|
user User @relation("UserChecks", fields: [userId], references: [id])
|
||||||
|
|
||||||
@@map("check")
|
@@map("check")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
price Float
|
price Float
|
||||||
// planId Int?
|
// planId Int?
|
||||||
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
// plan Plan? @relation("PlanServices", fields: [planId], references: [id])
|
||||||
|
|
||||||
@@map("service")
|
@@map("service")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ticket {
|
model Ticket {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
message String @db.Text
|
message String @db.Text
|
||||||
userId Int
|
userId Int
|
||||||
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
|
status String @default("open") // open, in_progress, awaiting_reply, resolved, closed
|
||||||
priority String @default("normal") // low, normal, high, urgent
|
priority String @default("normal") // low, normal, high, urgent
|
||||||
category String @default("general") // general, technical, billing, other
|
category String @default("general") // general, technical, billing, other
|
||||||
assignedTo Int? // ID оператора, которому назначен тикет
|
assignedTo Int? // ID оператора, которому назначен тикет
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
closedAt DateTime?
|
closedAt DateTime?
|
||||||
responses Response[] @relation("TicketResponses")
|
responses Response[] @relation("TicketResponses")
|
||||||
attachments TicketAttachment[]
|
attachments TicketAttachment[]
|
||||||
user User? @relation("UserTickets", fields: [userId], references: [id])
|
user User? @relation("UserTickets", fields: [userId], references: [id])
|
||||||
|
|
||||||
@@map("ticket")
|
@@map("ticket")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Response {
|
model Response {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
ticketId Int
|
ticketId Int
|
||||||
operatorId Int
|
operatorId Int
|
||||||
message String @db.Text
|
message String @db.Text
|
||||||
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
|
isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
|
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
|
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
|
||||||
attachments ResponseAttachment[]
|
attachments ResponseAttachment[]
|
||||||
|
|
||||||
@@map("response")
|
@@map("response")
|
||||||
@@ -102,14 +103,14 @@ model Response {
|
|||||||
|
|
||||||
// Прикреплённые файлы к тикетам
|
// Прикреплённые файлы к тикетам
|
||||||
model TicketAttachment {
|
model TicketAttachment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
ticketId Int
|
ticketId Int
|
||||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
filename String
|
filename String
|
||||||
fileUrl String
|
fileUrl String
|
||||||
fileSize Int // Размер в байтах
|
fileSize Int // Размер в байтах
|
||||||
mimeType String
|
mimeType String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -122,30 +123,30 @@ model ResponseAttachment {
|
|||||||
responseId Int
|
responseId Int
|
||||||
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
filename String
|
filename String
|
||||||
fileUrl String
|
fileUrl String
|
||||||
fileSize Int
|
fileSize Int
|
||||||
mimeType String
|
mimeType String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("response_attachment")
|
@@map("response_attachment")
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR-код авторизация (как в Telegram Web)
|
// QR-код авторизация (как в Telegram Web)
|
||||||
model QrLoginRequest {
|
model QrLoginRequest {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
code String @unique @db.VarChar(128) // Уникальный код QR
|
code String @unique @db.VarChar(128) // Уникальный код QR
|
||||||
userId Int? // После подтверждения - ID пользователя
|
userId Int? // После подтверждения - ID пользователя
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
status String @default("pending") // pending, confirmed, expired, rejected
|
status String @default("pending") // pending, confirmed, expired, rejected
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String? @db.Text
|
userAgent String? @db.Text
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime // Через 60 секунд
|
expiresAt DateTime // Через 60 секунд
|
||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
|
|
||||||
@@index([code])
|
@@index([code])
|
||||||
@@index([status, expiresAt])
|
@@index([status, expiresAt])
|
||||||
@@ -154,34 +155,34 @@ model QrLoginRequest {
|
|||||||
|
|
||||||
// История всех транзакций (пополнения, списания, возвраты)
|
// История всех транзакций (пополнения, списания, возвраты)
|
||||||
model Transaction {
|
model Transaction {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
amount Float
|
amount Float
|
||||||
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
|
type String // deposit (пополнение), withdrawal (списание), refund (возврат)
|
||||||
description String
|
description String
|
||||||
balanceBefore Float
|
balanceBefore Float
|
||||||
balanceAfter Float
|
balanceAfter Float
|
||||||
adminId Int? // ID админа, если операция выполнена админом
|
adminId Int? // ID админа, если операция выполнена админом
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@map("transaction")
|
@@map("transaction")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Блог
|
// Блог
|
||||||
model Post {
|
model Post {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title String
|
||||||
content String @db.Text // Rich text content (HTML)
|
content String @db.Text // Rich text content (HTML)
|
||||||
excerpt String? @db.Text // Краткое описание для ленты
|
excerpt String? @db.Text // Краткое описание для ленты
|
||||||
coverImage String? // URL обложки
|
coverImage String? // URL обложки
|
||||||
url String @unique // Пользовательский URL (blog_name)
|
url String @unique // Пользовательский URL (blog_name)
|
||||||
status String @default("draft") // draft, published, archived
|
status String @default("draft") // draft, published, archived
|
||||||
authorId Int
|
authorId Int
|
||||||
author User @relation("PostAuthor", fields: [authorId], references: [id])
|
author User @relation("PostAuthor", fields: [authorId], references: [id])
|
||||||
views Int @default(0)
|
views Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
publishedAt DateTime?
|
publishedAt DateTime?
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
|
||||||
@@ -190,37 +191,37 @@ model Post {
|
|||||||
|
|
||||||
// Комментарии к статьям блога
|
// Комментарии к статьям блога
|
||||||
model Comment {
|
model Comment {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
postId Int
|
postId Int
|
||||||
userId Int? // null если комментарий от гостя
|
userId Int? // null если комментарий от гостя
|
||||||
authorName String? // Имя автора (для гостей)
|
authorName String? // Имя автора (для гостей)
|
||||||
content String @db.Text
|
content String @db.Text
|
||||||
status String @default("pending") // pending, approved, rejected
|
status String @default("pending") // pending, approved, rejected
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
user User? @relation("UserComments", fields: [userId], references: [id])
|
user User? @relation("UserComments", fields: [userId], references: [id])
|
||||||
|
|
||||||
@@map("comment")
|
@@map("comment")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модель для уведомлений
|
// Модель для уведомлений
|
||||||
model Notification {
|
model Notification {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
|
type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low
|
||||||
title String
|
title String
|
||||||
message String @db.Text
|
message String @db.Text
|
||||||
|
|
||||||
// Связанные сущности (опционально)
|
// Связанные сущности (опционально)
|
||||||
ticketId Int?
|
ticketId Int?
|
||||||
checkId Int?
|
checkId Int?
|
||||||
|
|
||||||
// Метаданные
|
// Метаданные
|
||||||
actionUrl String? // URL для перехода при клике
|
actionUrl String? // URL для перехода при клике
|
||||||
icon String? // Иконка (emoji или path)
|
icon String? // Иконка (emoji или path)
|
||||||
color String? // Цвет (green, blue, orange, red, purple)
|
color String? // Цвет (green, blue, orange, red, purple)
|
||||||
|
|
||||||
isRead Boolean @default(false)
|
isRead Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -232,13 +233,13 @@ model Notification {
|
|||||||
|
|
||||||
// Модель для Push-подписок (Web Push API)
|
// Модель для Push-подписок (Web Push API)
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
endpoint String @db.VarChar(512)
|
endpoint String @db.VarChar(512)
|
||||||
p256dh String @db.Text // Публичный ключ для шифрования
|
p256dh String @db.Text // Публичный ключ для шифрования
|
||||||
auth String @db.Text // Токен аутентификации
|
auth String @db.Text // Токен аутентификации
|
||||||
|
|
||||||
userAgent String? @db.Text // Браузер/устройство
|
userAgent String? @db.Text // Браузер/устройство
|
||||||
|
|
||||||
@@ -252,16 +253,16 @@ model PushSubscription {
|
|||||||
|
|
||||||
// Активные сеансы пользователя
|
// Активные сеансы пользователя
|
||||||
model Session {
|
model Session {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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?
|
ipAddress String?
|
||||||
userAgent String? @db.Text
|
userAgent String? @db.Text
|
||||||
device String? // Desktop, Mobile, Tablet
|
device String? // Desktop, Mobile, Tablet
|
||||||
browser String? // Chrome, Firefox, Safari, etc.
|
browser String? // Chrome, Firefox, Safari, etc.
|
||||||
location String? // Город/страна по IP
|
location String? // Город/страна по IP
|
||||||
|
|
||||||
lastActivity DateTime @default(now())
|
lastActivity DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -273,12 +274,12 @@ model Session {
|
|||||||
|
|
||||||
// История входов
|
// История входов
|
||||||
model LoginHistory {
|
model LoginHistory {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
ipAddress String
|
ipAddress String
|
||||||
userAgent String? @db.Text
|
userAgent String? @db.Text
|
||||||
device String?
|
device String?
|
||||||
browser String?
|
browser String?
|
||||||
location String?
|
location String?
|
||||||
@@ -293,19 +294,19 @@ model LoginHistory {
|
|||||||
|
|
||||||
// API ключи для разработчиков
|
// API ключи для разработчиков
|
||||||
model APIKey {
|
model APIKey {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
name String // Название (например, "Production API")
|
name String // Название (например, "Production API")
|
||||||
key String @unique @db.VarChar(64) // Сам API ключ
|
key String @unique @db.VarChar(64) // Сам API ключ
|
||||||
prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx)
|
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?
|
lastUsed DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([key])
|
@@index([key])
|
||||||
@@ -319,15 +320,15 @@ model NotificationSettings {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
// Email уведомления
|
// Email уведомления
|
||||||
emailBalanceLow Boolean @default(true)
|
emailBalanceLow Boolean @default(true)
|
||||||
emailPaymentCharged Boolean @default(true)
|
emailPaymentCharged Boolean @default(true)
|
||||||
emailTicketReply Boolean @default(true)
|
emailTicketReply Boolean @default(true)
|
||||||
emailNewsletter Boolean @default(false)
|
emailNewsletter Boolean @default(false)
|
||||||
|
|
||||||
// Push уведомления
|
// Push уведомления
|
||||||
pushBalanceLow Boolean @default(true)
|
pushBalanceLow Boolean @default(true)
|
||||||
pushPaymentCharged Boolean @default(true)
|
pushPaymentCharged Boolean @default(true)
|
||||||
pushTicketReply Boolean @default(true)
|
pushTicketReply Boolean @default(true)
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -336,14 +337,14 @@ model NotificationSettings {
|
|||||||
|
|
||||||
// Настройки профиля
|
// Настройки профиля
|
||||||
model UserProfile {
|
model UserProfile {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @unique
|
userId Int @unique
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
avatarUrl String? // Путь к аватару
|
avatarUrl String? // Путь к аватару
|
||||||
phoneNumber String?
|
phoneNumber String?
|
||||||
timezone String? @default("Europe/Moscow")
|
timezone String? @default("Europe/Moscow")
|
||||||
language String? @default("ru")
|
language String? @default("ru")
|
||||||
|
|
||||||
// Настройки приватности
|
// Настройки приватности
|
||||||
profilePublic Boolean @default(false)
|
profilePublic Boolean @default(false)
|
||||||
@@ -353,59 +354,59 @@ model UserProfile {
|
|||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorSecret String? @db.Text
|
twoFactorSecret String? @db.Text
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@map("user_profile")
|
@@map("user_profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3 Bucket модель
|
// S3 Bucket модель
|
||||||
model StorageBucket {
|
model StorageBucket {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
name String // Уникальное имя бакета в рамках пользователя
|
name String // Уникальное имя бакета в рамках пользователя
|
||||||
plan String // Код тарифа из StoragePlan
|
plan String // Код тарифа из StoragePlan
|
||||||
quotaGb Int // Лимит включённого объёма в GB
|
quotaGb Int // Лимит включённого объёма в GB
|
||||||
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
|
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
|
||||||
objectCount Int @default(0)
|
objectCount Int @default(0)
|
||||||
storageClass String @default("standard") // standard, infrequent, archive
|
storageClass String @default("standard") // standard, infrequent, archive
|
||||||
region String @default("ru-central-1")
|
region String @default("ru-central-1")
|
||||||
public Boolean @default(false)
|
public Boolean @default(false)
|
||||||
versioning Boolean @default(false)
|
versioning Boolean @default(false)
|
||||||
status String @default("active") // active, grace, suspended
|
status String @default("active") // active, grace, suspended
|
||||||
monthlyPrice Float
|
monthlyPrice Float
|
||||||
nextBillingDate DateTime?
|
nextBillingDate DateTime?
|
||||||
lastBilledAt DateTime?
|
lastBilledAt DateTime?
|
||||||
autoRenew Boolean @default(true)
|
autoRenew Boolean @default(true)
|
||||||
usageSyncedAt DateTime?
|
usageSyncedAt DateTime?
|
||||||
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
|
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
|
||||||
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
|
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
|
||||||
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
|
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
|
||||||
consoleCredential StorageConsoleCredential?
|
consoleCredential StorageConsoleCredential?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
accessKeys StorageAccessKey[]
|
accessKeys StorageAccessKey[]
|
||||||
|
|
||||||
|
@@unique([userId, name]) // Имя уникально в рамках пользователя
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([region])
|
@@index([region])
|
||||||
@@index([storageClass])
|
@@index([storageClass])
|
||||||
@@unique([userId, name]) // Имя уникально в рамках пользователя
|
|
||||||
@@map("storage_bucket")
|
@@map("storage_bucket")
|
||||||
}
|
}
|
||||||
|
|
||||||
model StorageAccessKey {
|
model StorageAccessKey {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
bucketId Int
|
bucketId Int
|
||||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
accessKey String @unique
|
accessKey String @unique
|
||||||
secretKey String // хранится в зашифрованном виде
|
secretKey String // хранится в зашифрованном виде
|
||||||
label String?
|
label String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastUsedAt DateTime?
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
@@index([bucketId])
|
@@index([bucketId])
|
||||||
@@ -413,36 +414,36 @@ model StorageAccessKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model StorageConsoleCredential {
|
model StorageConsoleCredential {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
bucketId Int @unique
|
bucketId Int @unique
|
||||||
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
login String
|
login String
|
||||||
passwordHash String
|
passwordHash String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю)
|
lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю)
|
||||||
|
|
||||||
@@map("storage_console_credential")
|
@@map("storage_console_credential")
|
||||||
}
|
}
|
||||||
|
|
||||||
model StoragePlan {
|
model StoragePlan {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
price Float
|
price Float
|
||||||
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
|
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
|
||||||
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
|
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
|
||||||
requestsPerGb Int? // Количество операций на 1 GB хранения
|
requestsPerGb Int? // Количество операций на 1 GB хранения
|
||||||
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
|
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
|
||||||
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
|
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
|
||||||
requestLimit String // Текстовое описание лимита операций
|
requestLimit String // Текстовое описание лимита операций
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
description String?
|
description String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
buckets StorageBucket[]
|
buckets StorageBucket[]
|
||||||
checkoutSessions StorageCheckoutSession[]
|
checkoutSessions StorageCheckoutSession[]
|
||||||
@@ -451,9 +452,9 @@ model StoragePlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model StorageCheckoutSession {
|
model StorageCheckoutSession {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId Int?
|
userId Int?
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
planId Int
|
planId Int
|
||||||
plan StoragePlan @relation(fields: [planId], references: [id])
|
plan StoragePlan @relation(fields: [planId], references: [id])
|
||||||
planCode String
|
planCode String
|
||||||
@@ -461,12 +462,12 @@ model StorageCheckoutSession {
|
|||||||
planDescription String?
|
planDescription String?
|
||||||
price Float
|
price Float
|
||||||
promoCodeId Int?
|
promoCodeId Int?
|
||||||
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
|
promoCode PromoCode? @relation(fields: [promoCodeId], references: [id])
|
||||||
promoDiscount Float? @default(0)
|
promoDiscount Float? @default(0)
|
||||||
quotaGb Int
|
quotaGb Int
|
||||||
bandwidthGb Int
|
bandwidthGb Int
|
||||||
requestLimit String
|
requestLimit String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
consumedAt DateTime?
|
consumedAt DateTime?
|
||||||
|
|
||||||
@@ -486,40 +487,43 @@ model StorageRegion {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
buckets StorageBucket[] @relation("BucketRegion")
|
buckets StorageBucket[] @relation("BucketRegion")
|
||||||
|
|
||||||
@@map("storage_region")
|
@@map("storage_region")
|
||||||
}
|
}
|
||||||
|
|
||||||
model StorageClass {
|
model StorageClass {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
redundancy String?
|
redundancy String?
|
||||||
performance String?
|
performance String?
|
||||||
retrievalFee String?
|
retrievalFee String?
|
||||||
isDefault Boolean @default(false)
|
isDefault Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
buckets StorageBucket[] @relation("BucketClass")
|
buckets StorageBucket[] @relation("BucketClass")
|
||||||
|
|
||||||
@@map("storage_class")
|
@@map("storage_class")
|
||||||
}
|
}
|
||||||
|
|
||||||
model PromoCode {
|
model PromoCode {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
code String @unique
|
code String @unique
|
||||||
amount Float // discount amount in RUB
|
amount Float // discount amount in RUB
|
||||||
used Boolean @default(false)
|
used Boolean @default(false)
|
||||||
usedBy Int?
|
usedBy Int?
|
||||||
usedAt DateTime?
|
usedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User? @relation(fields: [usedBy], references: [id])
|
user User? @relation(fields: [usedBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
// Обратная связь для корзин, в которые применяли этот промокод
|
||||||
|
checkoutSessions StorageCheckoutSession[]
|
||||||
|
|
||||||
@@map("promo_code")
|
@@map("promo_code")
|
||||||
}
|
}
|
||||||
@@ -112,14 +112,71 @@ app.get('/', async (req, res) => {
|
|||||||
app.get('/sitemap.xml', (req, res) => {
|
app.get('/sitemap.xml', (req, res) => {
|
||||||
const baseUrl = 'https://ospab.host';
|
const baseUrl = 'https://ospab.host';
|
||||||
|
|
||||||
const staticPages = [
|
const pages = [
|
||||||
{ 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: '/',
|
||||||
{ loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' },
|
priority: '1.0',
|
||||||
{ loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' },
|
changefreq: 'weekly',
|
||||||
{ loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' },
|
ru: { title: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов', description: 'Надёжное облачное S3-совместимое хранилище в Великом Новгороде' },
|
||||||
{ loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' },
|
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';
|
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];
|
const lastmod = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
for (const page of staticPages) {
|
for (const page of pages) {
|
||||||
|
// Русская версия (без префикса)
|
||||||
xml += ' <url>\n';
|
xml += ' <url>\n';
|
||||||
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
|
xml += ` <loc>${baseUrl}${page.loc}</loc>\n`;
|
||||||
xml += ` <lastmod>${lastmod}</lastmod>\n`;
|
xml += ` <lastmod>${lastmod}</lastmod>\n`;
|
||||||
xml += ` <priority>${page.priority}</priority>\n`;
|
xml += ` <priority>${page.priority}</priority>\n`;
|
||||||
xml += ` <changefreq>${page.changefreq}</changefreq>\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';
|
xml += ' </url>\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,20 +215,29 @@ app.get('/sitemap.xml', (req, res) => {
|
|||||||
// ==================== ROBOTS.TXT ====================
|
// ==================== ROBOTS.TXT ====================
|
||||||
app.get('/robots.txt', (req, res) => {
|
app.get('/robots.txt', (req, res) => {
|
||||||
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
|
const robots = `# ospab Host - Облачное S3 хранилище и хостинг
|
||||||
# Хранение данных, техподдержка 24/7
|
# Cloud S3 Storage and Website Hosting
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /about
|
Allow: /about
|
||||||
|
Allow: /en/about
|
||||||
Allow: /login
|
Allow: /login
|
||||||
|
Allow: /en/login
|
||||||
Allow: /register
|
Allow: /register
|
||||||
|
Allow: /en/register
|
||||||
Allow: /blog
|
Allow: /blog
|
||||||
|
Allow: /en/blog
|
||||||
Allow: /blog/*
|
Allow: /blog/*
|
||||||
|
Allow: /en/blog/*
|
||||||
|
Allow: /tariffs
|
||||||
|
Allow: /en/tariffs
|
||||||
Allow: /terms
|
Allow: /terms
|
||||||
|
Allow: /en/terms
|
||||||
Allow: /privacy
|
Allow: /privacy
|
||||||
|
Allow: /en/privacy
|
||||||
Allow: /uploads/blog
|
Allow: /uploads/blog
|
||||||
|
|
||||||
# Запрет индексации приватных разделов
|
# Disallow private sections / Запрет индексации приватных разделов
|
||||||
Disallow: /dashboard
|
Disallow: /dashboard
|
||||||
Disallow: /dashboard/*
|
Disallow: /dashboard/*
|
||||||
Disallow: /api/
|
Disallow: /api/
|
||||||
@@ -171,7 +250,7 @@ Disallow: /uploads/checks
|
|||||||
|
|
||||||
Sitemap: https://ospab.host/sitemap.xml
|
Sitemap: https://ospab.host/sitemap.xml
|
||||||
|
|
||||||
# Поисковые роботы
|
# Search engine robots / Поисковые роботы
|
||||||
User-agent: Googlebot
|
User-agent: Googlebot
|
||||||
Allow: /
|
Allow: /
|
||||||
Crawl-delay: 0
|
Crawl-delay: 0
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ import {
|
|||||||
} from './account.service';
|
} from './account.service';
|
||||||
import { prisma } from '../../prisma/client';
|
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) => {
|
export const getAccountInfo = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
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) => {
|
export const requestPasswordChangeHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
return res.status(401).json({ error: 'Не авторизован' });
|
||||||
}
|
}
|
||||||
@@ -71,7 +77,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка запроса смены пароля:', error);
|
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) => {
|
export const confirmPasswordChangeHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
return res.status(401).json({ error: 'Не авторизован' });
|
||||||
}
|
}
|
||||||
@@ -99,7 +105,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка подтверждения смены пароля:', error);
|
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) => {
|
export const requestUsernameChangeHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
return res.status(401).json({ error: 'Не авторизован' });
|
||||||
}
|
}
|
||||||
@@ -139,7 +145,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка запроса смены имени:', error);
|
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) => {
|
export const confirmUsernameChangeHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
return res.status(401).json({ error: 'Не авторизован' });
|
||||||
}
|
}
|
||||||
@@ -167,7 +173,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка подтверждения смены имени:', error);
|
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) => {
|
export const requestAccountDeletionHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
return res.status(401).json({ error: 'Не авторизован' });
|
||||||
}
|
}
|
||||||
@@ -189,7 +195,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка запроса удаления аккаунта:', error);
|
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) => {
|
export const confirmAccountDeletionHandler = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const userId = (req.user as any)?.id;
|
const userId = (req.user as { id?: number })?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return res.status(401).json({ error: 'Не авторизован' });
|
return res.status(401).json({ error: 'Не авторизован' });
|
||||||
}
|
}
|
||||||
@@ -217,7 +223,8 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Ошибка подтверждения удаления аккаунта:', error);
|
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 { Request, Response } from 'express';
|
||||||
import { prisma } from '../../prisma/client';
|
import { prisma } from '../../prisma/client';
|
||||||
import { createNotification } from '../notification/notification.controller';
|
import { createNotification } from '../notification/notification.controller';
|
||||||
|
import { sendNotificationEmail } from '../notification/email.service';
|
||||||
|
|
||||||
function toNumeric(value: unknown): number {
|
function toNumeric(value: unknown): number {
|
||||||
if (typeof value === 'bigint') {
|
if (typeof value === 'bigint') {
|
||||||
@@ -514,23 +515,50 @@ export class AdminController {
|
|||||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
return res.status(400).json({ error: 'У пользователя не указан email' });
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
|
const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`;
|
||||||
console.log(logMsg);
|
console.log(logMsg);
|
||||||
|
|
||||||
// Здесь должна быть реальная отправка email (имитация)
|
// Отправляем реальное email уведомление
|
||||||
await new Promise(resolve => setTimeout(resolve, 800));
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Email-уведомление успешно отправлено (тест)',
|
message: 'Email-уведомление успешно отправлено',
|
||||||
details: {
|
details: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
type: 'email',
|
type: 'email',
|
||||||
time: now,
|
time: now,
|
||||||
status: 'sent (mock)'
|
messageId: 'messageId' in emailResult ? emailResult.messageId : undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import crypto from 'crypto';
|
|||||||
import { createSession } from '../session/session.controller';
|
import { createSession } from '../session/session.controller';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд
|
const QR_EXPIRATION_SECONDS = 180; // QR-код живёт 180 секунд (3 минуты)
|
||||||
|
|
||||||
// Генерировать уникальный код для QR
|
// Генерировать уникальный код для QR
|
||||||
function generateQRCode(): string {
|
function generateQRCode(): string {
|
||||||
@@ -14,7 +14,7 @@ function generateQRCode(): string {
|
|||||||
// Создать новый QR-запрос для логина
|
// Создать новый QR-запрос для логина
|
||||||
export async function createQRLoginRequest(req: Request, res: Response) {
|
export async function createQRLoginRequest(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const code = generateQRCode();
|
const code = generateQRCode();
|
||||||
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
|
const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || '';
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
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({
|
res.json({
|
||||||
code: qrRequest.code,
|
code: qrRequest.code,
|
||||||
expiresAt: qrRequest.expiresAt,
|
expiresAt: qrRequest.expiresAt,
|
||||||
@@ -47,21 +57,38 @@ export async function checkQRStatus(req: Request, res: Response) {
|
|||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
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({
|
const qrRequest = await prisma.qrLoginRequest.findUnique({
|
||||||
where: { code }
|
where: { code }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!qrRequest) {
|
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-код не найден' });
|
return res.status(404).json({ error: 'QR-код не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем истёк ли 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({
|
await prisma.qrLoginRequest.update({
|
||||||
where: { code },
|
where: { code },
|
||||||
data: { status: 'expired' }
|
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);
|
const { token } = await createSession(user.id, req);
|
||||||
|
|
||||||
// Удаляем использованный QR-запрос
|
// Попытка безопасно удалить использованный QR-запрос (deleteMany не бросает если записи не найдено)
|
||||||
await prisma.qrLoginRequest.delete({ where: { code } });
|
try {
|
||||||
|
await prisma.qrLoginRequest.deleteMany({ where: { code } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[QR Status] Не удалось удалить QR-запрос (возможно уже удалён)', { code, error: err });
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
status: 'confirmed',
|
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) {
|
} catch (error) {
|
||||||
logger.error('Ошибка проверки статуса QR:', error);
|
logger.error('Ошибка проверки статуса QR:', error);
|
||||||
res.status(500).json({ error: 'Ошибка проверки статуса' });
|
res.status(500).json({ error: 'Ошибка проверки статуса' });
|
||||||
@@ -266,3 +297,61 @@ export async function cleanupExpiredQRRequests() {
|
|||||||
logger.error('[QR Cleanup] Ошибка:', error);
|
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,
|
checkQRStatus,
|
||||||
confirmQRLogin,
|
confirmQRLogin,
|
||||||
rejectQRLogin,
|
rejectQRLogin,
|
||||||
markQRAsScanning
|
markQRAsScanning,
|
||||||
|
listRecentQRRequests,
|
||||||
|
getQRRequestByCode
|
||||||
} from './qr-auth.controller';
|
} from './qr-auth.controller';
|
||||||
import { authMiddleware } from '../auth/auth.middleware';
|
import { authMiddleware } from '../auth/auth.middleware';
|
||||||
|
|
||||||
@@ -16,6 +18,10 @@ router.post('/generate', createQRLoginRequest);
|
|||||||
// Проверить статус QR-кода (polling, публичный endpoint)
|
// Проверить статус QR-кода (polling, публичный endpoint)
|
||||||
router.get('/status/:code', checkQRStatus);
|
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);
|
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 });
|
return res.json({ success: true, cart: result });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e instanceof Error ? e.message : 'Не удалось применить промокод';
|
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,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Для custom тарифа всегда берём значения из сессии
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
price: toPlainNumber(session.price),
|
price: toPlainNumber(session.price),
|
||||||
|
quotaGb: session.quotaGb,
|
||||||
|
bandwidthGb: session.bandwidthGb,
|
||||||
|
requestLimit: session.requestLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +676,8 @@ type CheckoutSessionPayload = {
|
|||||||
plan: ReturnType<typeof serializePlan>;
|
plan: ReturnType<typeof serializePlan>;
|
||||||
price: number;
|
price: number;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
originalPrice?: number | null;
|
||||||
|
promoDiscount?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CheckoutSessionResult = {
|
type CheckoutSessionResult = {
|
||||||
@@ -694,11 +700,14 @@ function ensureSessionActive(session: CheckoutSessionRecord, userId: number): Ch
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toCheckoutPayload(session: CheckoutSessionRecord, plan?: StoragePlanRecord | null): CheckoutSessionPayload {
|
function toCheckoutPayload(session: CheckoutSessionRecord, plan?: StoragePlanRecord | null): CheckoutSessionPayload {
|
||||||
|
const original = plan ? Number(plan.price) : toPlainNumber(session.price);
|
||||||
return {
|
return {
|
||||||
cartId: session.id,
|
cartId: session.id,
|
||||||
plan: buildPlanFromSession(session, plan),
|
plan: buildPlanFromSession(session, plan),
|
||||||
price: toPlainNumber(session.price),
|
price: toPlainNumber(session.price),
|
||||||
expiresAt: session.expiresAt.toISOString(),
|
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 BadGateway from './pages/502';
|
||||||
import ServiceUnavailable from './pages/503';
|
import ServiceUnavailable from './pages/503';
|
||||||
import GatewayTimeout from './pages/504';
|
import GatewayTimeout from './pages/504';
|
||||||
|
import ErrorPage from './pages/errors';
|
||||||
|
import NetworkError from './pages/errors/NetworkError';
|
||||||
import Privateroute from './components/privateroute';
|
import Privateroute from './components/privateroute';
|
||||||
import { AuthProvider } from './context/authcontext';
|
import { AuthProvider } from './context/authcontext';
|
||||||
import { WebSocketProvider } from './context/WebSocketContext';
|
import { WebSocketProvider } from './context/WebSocketContext';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
import { LocaleProvider } from './middleware';
|
import { LocaleProvider, useLocale } from './middleware';
|
||||||
|
|
||||||
// SEO конфиг для всех маршрутов
|
// SEO конфиг для всех маршрутов с поддержкой локализации
|
||||||
const SEO_CONFIG: Record<string, {
|
const SEO_CONFIG: Record<string, {
|
||||||
title: string;
|
ru: {
|
||||||
description: string;
|
|
||||||
keywords: string;
|
|
||||||
og?: {
|
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
image: string;
|
keywords: string;
|
||||||
url: 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 хранилище',
|
ru: {
|
||||||
description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.',
|
title: 'Облачное S3 хранилище',
|
||||||
keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage',
|
description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.',
|
||||||
og: {
|
keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage',
|
||||||
title: 'ospab.host - Облачное S3 хранилище',
|
og: {
|
||||||
description: 'S3-совместимое хранилище с поддержкой 24/7',
|
title: 'ospab.host - Облачное S3 хранилище',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'S3-совместимое хранилище с поддержкой 24/7',
|
||||||
url: 'https://ospab.host/',
|
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': {
|
'/about': {
|
||||||
title: 'О компании - Ospab Host',
|
ru: {
|
||||||
description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.',
|
title: 'О компании - Ospab Host',
|
||||||
keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород',
|
description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.',
|
||||||
og: {
|
keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород',
|
||||||
title: 'О компании ospab.host',
|
og: {
|
||||||
description: 'Современная платформа облачного хранилища',
|
title: 'О компании ospab.host',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'Современная платформа облачного хранилища',
|
||||||
url: 'https://ospab.host/about',
|
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': {
|
'/login': {
|
||||||
title: 'Вход в аккаунт - Ospab Host',
|
ru: {
|
||||||
description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.',
|
title: 'Вход в аккаунт - Ospab Host',
|
||||||
keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления',
|
description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.',
|
||||||
og: {
|
keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления',
|
||||||
title: 'Вход в ospab.host',
|
og: {
|
||||||
description: 'Доступ к панели управления',
|
title: 'Вход в ospab.host',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'Доступ к панели управления',
|
||||||
url: 'https://ospab.host/login',
|
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': {
|
'/register': {
|
||||||
title: 'Регистрация - Создать аккаунт',
|
ru: {
|
||||||
description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.',
|
title: 'Регистрация - Создать аккаунт',
|
||||||
keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт',
|
description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.',
|
||||||
og: {
|
keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт',
|
||||||
title: 'Регистрация в ospab.host',
|
og: {
|
||||||
description: 'Создайте аккаунт и начните использовать S3 хранилище',
|
title: 'Регистрация в ospab.host',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'Создайте аккаунт и начните использовать S3 хранилище',
|
||||||
url: 'https://ospab.host/register',
|
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': {
|
'/blog': {
|
||||||
title: 'Блог о хостинге и S3',
|
ru: {
|
||||||
description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.',
|
title: 'Блог о хостинге и S3',
|
||||||
keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage',
|
description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.',
|
||||||
og: {
|
keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage',
|
||||||
title: 'Блог ospab.host',
|
og: {
|
||||||
description: 'Статьи о хостинге и DevOps',
|
title: 'Блог ospab.host',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'Статьи о хостинге и DevOps',
|
||||||
url: 'https://ospab.host/blog',
|
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': {
|
'/terms': {
|
||||||
title: 'Условия использования',
|
ru: {
|
||||||
description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.',
|
title: 'Условия использования',
|
||||||
keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия',
|
description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.',
|
||||||
og: {
|
keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия',
|
||||||
title: 'Условия использования ospab.host',
|
og: {
|
||||||
description: 'Полные условия использования сервиса',
|
title: 'Условия использования ospab.host',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'Полные условия использования сервиса',
|
||||||
url: 'https://ospab.host/terms',
|
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': {
|
'/privacy': {
|
||||||
title: 'Политика конфиденциальности',
|
ru: {
|
||||||
description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.',
|
title: 'Политика конфиденциальности',
|
||||||
keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных',
|
description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.',
|
||||||
og: {
|
keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных',
|
||||||
title: 'Политика конфиденциальности ospab.host',
|
og: {
|
||||||
description: 'Защита ваших данных и приватности',
|
title: 'Политика конфиденциальности ospab.host',
|
||||||
image: 'https://ospab.host/og-image.jpg',
|
description: 'Защита ваших данных и приватности',
|
||||||
url: 'https://ospab.host/privacy',
|
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 при изменении маршрута
|
// Компонент для обновления SEO при изменении маршрута
|
||||||
function SEOUpdater() {
|
function SEOUpdater() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
|
|
||||||
// Получаем SEO данные для текущего маршрута, иначе используем дефолтные
|
// Получаем SEO данные для текущего маршрута, иначе используем дефолтные
|
||||||
const seoData = SEO_CONFIG[pathname] || {
|
const seoConfig = SEO_CONFIG[pathname];
|
||||||
title: 'ospab.host - облачный хостинг',
|
const seoData = seoConfig ? seoConfig[locale] : {
|
||||||
description: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.',
|
title: locale === 'en' ? 'ospab.host - cloud hosting' : 'ospab.host - облачный хостинг',
|
||||||
keywords: 'хостинг, облачный хостинг, VPS, VDS',
|
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
|
// Устанавливаем title
|
||||||
@@ -186,7 +298,7 @@ function SEOUpdater() {
|
|||||||
|
|
||||||
// Скроллим вверх при навигации
|
// Скроллим вверх при навигации
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, [location.pathname]);
|
}, [location.pathname, locale]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -256,6 +368,15 @@ function App() {
|
|||||||
<Route path="/en/502" element={<BadGateway />} />
|
<Route path="/en/502" element={<BadGateway />} />
|
||||||
<Route path="/en/503" element={<ServiceUnavailable />} />
|
<Route path="/en/503" element={<ServiceUnavailable />} />
|
||||||
<Route path="/en/504" element={<GatewayTimeout />} />
|
<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 />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
interface ErrorPageProps {
|
interface ErrorPageProps {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -38,10 +40,13 @@ export default function ErrorPage({
|
|||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
showHomeButton = true,
|
showHomeButton = true,
|
||||||
}: ErrorPageProps) {
|
}: ErrorPageProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white px-4">
|
<div className="min-h-screen flex items-center justify-center bg-white px-4">
|
||||||
<div className="max-w-md w-full text-center">
|
<div className="max-w-md w-full text-center">
|
||||||
{/* Код ошибки */}
|
{/* Error code */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-8xl font-bold text-gray-200 mb-4">{code}</h1>
|
<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]}`}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок */}
|
{/* Title */}
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Описание */}
|
{/* Description */}
|
||||||
<p className="text-gray-600 mb-8">
|
<p className="text-gray-600 mb-8">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Кнопки */}
|
{/* Buttons */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{showHomeButton && (
|
{showHomeButton && (
|
||||||
<Link
|
<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`}
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showLoginButton && (
|
{showLoginButton && (
|
||||||
<Link
|
<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`}
|
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>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -84,16 +89,16 @@ export default function ErrorPage({
|
|||||||
onClick={() => window.history.back()}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Контактная информация (опционально) */}
|
{/* Contact info (optional) */}
|
||||||
{(code === '500' || code === '503') && (
|
{(code === '500' || code === '503') && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Если проблема сохраняется, свяжитесь с нами:{' '}
|
{t('footer.contact')}:{' '}
|
||||||
<a
|
<a
|
||||||
href="mailto:support@ospab.host"
|
href="mailto:support@ospab.host"
|
||||||
className={`${color === 'red' ? 'text-red-600' : color === 'orange' ? 'text-orange-600' : 'text-gray-600'} hover:underline font-medium`}
|
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 { getUnreadCount, getNotifications, markAsRead, type Notification } from '../services/notificationService';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
import { wsLogger } from '../utils/logger';
|
import { wsLogger } from '../utils/logger';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
const NotificationBell = () => {
|
const NotificationBell = () => {
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
@@ -10,6 +11,8 @@ const NotificationBell = () => {
|
|||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { subscribe, unsubscribe, isConnected } = useWebSocket();
|
const { subscribe, unsubscribe, isConnected } = useWebSocket();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
// WebSocket обработчик событий
|
// WebSocket обработчик событий
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -115,12 +118,19 @@ const NotificationBell = () => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
|
||||||
if (diffInSeconds < 60) return 'только что';
|
if (isEn) {
|
||||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`;
|
if (diffInSeconds < 60) return 'just now';
|
||||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`;
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
|
||||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`;
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} h ago`;
|
||||||
|
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} d ago`;
|
||||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
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 (
|
return (
|
||||||
@@ -129,7 +139,7 @@ const NotificationBell = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="relative p-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="relative p-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||||
aria-label="Уведомления"
|
aria-label={isEn ? 'Notifications' : 'Уведомления'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-6 h-6"
|
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="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">
|
<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
|
<Link
|
||||||
to="/dashboard/notifications"
|
to="/dashboard/notifications"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="text-sm text-ospab-primary hover:underline"
|
className="text-sm text-ospab-primary hover:underline"
|
||||||
>
|
>
|
||||||
Все
|
{isEn ? 'All' : 'Все'}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<p>Нет уведомлений</p>
|
<p>{isEn ? 'No notifications' : 'Нет уведомлений'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notification) => (
|
notifications.map((notification) => (
|
||||||
@@ -240,7 +250,7 @@ const NotificationBell = () => {
|
|||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="block w-full text-center py-2 text-sm text-ospab-primary hover:bg-gray-50 rounded-md transition-colors"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import useAuth from '../context/useAuth';
|
import useAuth from '../context/useAuth';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
interface QRLoginProps {
|
interface QRLoginProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@@ -11,17 +12,22 @@ interface QRLoginProps {
|
|||||||
const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
const [qrCode, setQrCode] = useState<string>('');
|
const [qrCode, setQrCode] = useState<string>('');
|
||||||
const [status, setStatus] = useState<'generating' | 'waiting' | 'scanning' | 'expired' | 'error'>('generating');
|
const [status, setStatus] = useState<'generating' | 'waiting' | 'scanning' | 'expired' | 'error'>('generating');
|
||||||
const [pollingInterval, setPollingInterval] = useState<ReturnType<typeof setInterval> | null>(null);
|
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 : '';
|
const qrLinkBase = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
generateQR();
|
generateQR();
|
||||||
return () => {
|
return () => {
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
if (pollingInterval) clearInterval(pollingInterval);
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
if (countdownInterval) clearInterval(countdownInterval);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -34,12 +40,29 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
setStatus('waiting');
|
setStatus('waiting');
|
||||||
startPolling(response.data.code);
|
startPolling(response.data.code);
|
||||||
|
|
||||||
// Автоматическое обновление QR-кода каждые 60 секунд
|
// Устанавливаем время истечения и запускаем секундный таймер
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
const expires = response.data.expiresAt ? new Date(response.data.expiresAt).getTime() : (Date.now() + (response.data.expiresIn || 180) * 1000);
|
||||||
const interval = setInterval(() => {
|
const initialRemaining = Math.max(0, Math.ceil((expires - Date.now()) / 1000));
|
||||||
generateQR();
|
setRemaining(initialRemaining);
|
||||||
}, 60000);
|
|
||||||
setRefreshInterval(interval);
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка генерации QR:', error);
|
console.error('Ошибка генерации QR:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
@@ -51,6 +74,16 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/qr-auth/status/${code}`);
|
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" (пользователь открыл страницу подтверждения)
|
// Если статус изменился на "scanning" (пользователь открыл страницу подтверждения)
|
||||||
if (response.data.status === 'scanning') {
|
if (response.data.status === 'scanning') {
|
||||||
setStatus('scanning');
|
setStatus('scanning');
|
||||||
@@ -59,6 +92,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
if (response.data.status === 'confirmed' && response.data.token) {
|
if (response.data.status === 'confirmed' && response.data.token) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setPollingInterval(null);
|
setPollingInterval(null);
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||||
|
|
||||||
// Вызываем login из контекста для обновления состояния
|
// Вызываем login из контекста для обновления состояния
|
||||||
login(response.data.token);
|
login(response.data.token);
|
||||||
@@ -71,6 +105,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
} else if (response.data.status === 'rejected') {
|
} else if (response.data.status === 'rejected') {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setPollingInterval(null);
|
setPollingInterval(null);
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -78,7 +113,15 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
if (axiosError.response?.status === 404 || axiosError.response?.status === 410) {
|
if (axiosError.response?.status === 404 || axiosError.response?.status === 410) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setPollingInterval(null);
|
setPollingInterval(null);
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||||
setStatus('expired');
|
setStatus('expired');
|
||||||
|
} else {
|
||||||
|
// Для прочих ошибок (500 и т.д.) прекращаем пуллинг и показываем ошибку
|
||||||
|
clearInterval(interval);
|
||||||
|
setPollingInterval(null);
|
||||||
|
if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); }
|
||||||
|
console.error('Ошибка при проверке статуса QR:', error);
|
||||||
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 2000); // Проверка каждые 2 секунды
|
}, 2000); // Проверка каждые 2 секунды
|
||||||
@@ -89,15 +132,15 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
const getStatusMessage = () => {
|
const getStatusMessage = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'generating':
|
case 'generating':
|
||||||
return 'Генерация...';
|
return isEn ? 'Generating...' : 'Генерация...';
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
return 'Отсканируйте QR-код телефоном, на котором вы уже авторизованы';
|
return isEn ? 'Scan the QR code with your phone where you are already logged in' : 'Отсканируйте QR-код телефоном, на котором вы уже авторизованы';
|
||||||
case 'scanning':
|
case 'scanning':
|
||||||
return 'Ожидание подтверждения на телефоне...';
|
return isEn ? 'Waiting for confirmation on phone...' : 'Ожидание подтверждения на телефоне...';
|
||||||
case 'expired':
|
case 'expired':
|
||||||
return 'QR-код истёк';
|
return isEn ? 'QR code expired' : 'QR-код истёк';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Ошибка';
|
return isEn ? 'Error' : 'Ошибка';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -106,7 +149,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
|
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
|
||||||
<div className="text-center mb-6">
|
<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">
|
<p className="text-gray-600 text-sm">
|
||||||
{getStatusMessage()}
|
{getStatusMessage()}
|
||||||
</p>
|
</p>
|
||||||
@@ -129,6 +172,23 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
includeMargin={true}
|
includeMargin={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -139,7 +199,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
onClick={generateQR}
|
onClick={generateQR}
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -151,7 +211,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
onClick={generateQR}
|
onClick={generateQR}
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +223,7 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
|
|||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="text-blue-500 hover:text-blue-600 font-medium text-sm"
|
className="text-blue-500 hover:text-blue-600 font-medium text-sm"
|
||||||
>
|
>
|
||||||
Войти по паролю
|
{isEn ? 'Login with password' : 'Войти по паролю'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { API_URL } from '../config/api';
|
import { API_URL } from '../config/api';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
interface ServerMetricsProps {
|
interface ServerMetricsProps {
|
||||||
serverId: number;
|
serverId: number;
|
||||||
@@ -58,6 +59,8 @@ interface Summary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
const [period, setPeriod] = useState<'1h' | '6h' | '24h' | '7d' | '30d'>('24h');
|
const [period, setPeriod] = useState<'1h' | '6h' | '24h' | '7d' | '30d'>('24h');
|
||||||
const [history, setHistory] = useState<MetricData[]>([]);
|
const [history, setHistory] = useState<MetricData[]>([]);
|
||||||
const [current, setCurrent] = useState<CurrentMetrics | null>(null);
|
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 hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
if (days > 0) return `${days}д ${hours}ч`;
|
if (isEn) {
|
||||||
if (hours > 0) return `${hours}ч ${minutes}м`;
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
return `${minutes}м`;
|
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 formatTimestamp = (timestamp: string) => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
const localeStr = isEn ? 'en-US' : 'ru-RU';
|
||||||
if (period === '1h' || period === '6h') {
|
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') {
|
} 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 {
|
} 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');
|
const token = localStorage.getItem('access_token');
|
||||||
if (!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 };
|
const error = err as { response?: { status?: number; data?: { error?: string } }; message?: string };
|
||||||
console.error('❌ Ошибка загрузки метрик:', error);
|
console.error('❌ Ошибка загрузки метрик:', error);
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
setError('Ошибка авторизации. Пожалуйста, войдите снова.');
|
setError(isEn ? 'Authorization error. Please log in again.' : 'Ошибка авторизации. Пожалуйста, войдите снова.');
|
||||||
// Можно добавить редирект на логин
|
// Можно добавить редирект на логин
|
||||||
// window.location.href = '/login';
|
// window.location.href = '/login';
|
||||||
} else {
|
} else {
|
||||||
setError(error.response?.data?.error || error.message || 'Ошибка загрузки метрик');
|
setError(error.response?.data?.error || error.message || (isEn ? 'Error loading metrics' : 'Ошибка загрузки метрик'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -152,7 +162,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
if (loading && !current) {
|
if (loading && !current) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -165,7 +175,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
onClick={fetchMetrics}
|
onClick={fetchMetrics}
|
||||||
className="mt-2 text-sm text-red-600 hover:text-red-800 underline"
|
className="mt-2 text-sm text-red-600 hover:text-red-800 underline"
|
||||||
>
|
>
|
||||||
Попробовать снова
|
{isEn ? 'Try again' : 'Попробовать снова'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -193,7 +203,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
</div>
|
</div>
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +211,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
{/* Memory */}
|
{/* Memory */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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>
|
||||||
<div className="text-3xl font-bold text-gray-900">
|
<div className="text-3xl font-bold text-gray-900">
|
||||||
{current.memory.usage.toFixed(1)}%
|
{current.memory.usage.toFixed(1)}%
|
||||||
@@ -211,7 +221,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
</div>
|
</div>
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
Ср: {summary.memory.avg.toFixed(1)}%
|
{isEn ? 'Avg' : 'Ср'}: {summary.memory.avg.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +229,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
{/* Disk */}
|
{/* Disk */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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>
|
||||||
<div className="text-3xl font-bold text-gray-900">
|
<div className="text-3xl font-bold text-gray-900">
|
||||||
{current.disk.usage.toFixed(1)}%
|
{current.disk.usage.toFixed(1)}%
|
||||||
@@ -229,7 +239,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
</div>
|
</div>
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
Ср: {summary.disk.avg.toFixed(1)}%
|
{isEn ? 'Avg' : 'Ср'}: {summary.disk.avg.toFixed(1)}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +247,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
{/* Network */}
|
{/* Network */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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>
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
↓ {formatBytes(current.network.in)}
|
↓ {formatBytes(current.network.in)}
|
||||||
@@ -254,7 +264,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
|
|
||||||
{/* Фильтр периода */}
|
{/* Фильтр периода */}
|
||||||
<div className="flex items-center space-x-2">
|
<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) => (
|
{(['1h', '6h', '24h', '7d', '30d'] as const).map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
@@ -265,7 +275,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +285,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* CPU График */}
|
{/* CPU График */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<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}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<AreaChart data={history}>
|
<AreaChart data={history}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@@ -306,7 +316,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
|
|
||||||
{/* Memory и Disk */}
|
{/* Memory и Disk */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<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}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<LineChart data={history}>
|
<LineChart data={history}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@@ -329,14 +339,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="memoryUsage"
|
dataKey="memoryUsage"
|
||||||
stroke="#3B82F6"
|
stroke="#3B82F6"
|
||||||
name="Память"
|
name={isEn ? 'Memory' : 'Память'}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="diskUsage"
|
dataKey="diskUsage"
|
||||||
stroke="#10B981"
|
stroke="#10B981"
|
||||||
name="Диск"
|
name={isEn ? 'Disk' : 'Диск'}
|
||||||
dot={false}
|
dot={false}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
@@ -345,7 +355,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
|
|
||||||
{/* Network Traffic */}
|
{/* Network Traffic */}
|
||||||
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
<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}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
<AreaChart data={history}>
|
<AreaChart data={history}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
@@ -368,14 +378,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
dataKey="networkIn"
|
dataKey="networkIn"
|
||||||
stroke="#8B5CF6"
|
stroke="#8B5CF6"
|
||||||
fill="#C4B5FD"
|
fill="#C4B5FD"
|
||||||
name="Входящий"
|
name={isEn ? 'Incoming' : 'Входящий'}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="networkOut"
|
dataKey="networkOut"
|
||||||
stroke="#EC4899"
|
stroke="#EC4899"
|
||||||
fill="#F9A8D4"
|
fill="#F9A8D4"
|
||||||
name="Исходящий"
|
name={isEn ? 'Outgoing' : 'Исходящий'}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</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="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>
|
<div className="text-6xl mb-4">📊</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||||
{loading ? 'Загрузка данных...' : 'Нет данных за выбранный период'}
|
{loading ? (isEn ? 'Loading data...' : 'Загрузка данных...') : (isEn ? 'No data for selected period' : 'Нет данных за выбранный период')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<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>
|
</p>
|
||||||
{current && (
|
{current && (
|
||||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200 max-w-md mx-auto">
|
<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">
|
<p className="text-xs text-blue-700">
|
||||||
1. Откройте консоль сервера<br/>
|
{isEn ? '1. Open server console' : '1. Откройте консоль сервера'}<br/>
|
||||||
2. Запустите: <code className="bg-blue-100 px-2 py-1 rounded">stress-ng --cpu 2 --cpu-load 50 --timeout 180s</code><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/>
|
||||||
3. Обновите страницу через 1-2 минуты
|
{isEn ? '3. Refresh page in 1-2 minutes' : '3. Обновите страницу через 1-2 минуты'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -404,7 +414,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
|
|||||||
onClick={fetchMetrics}
|
onClick={fetchMetrics}
|
||||||
className="mt-6 px-6 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
className="mt-6 px-6 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||||
>
|
>
|
||||||
🔄 Обновить данные
|
{isEn ? '🔄 Refresh data' : '🔄 Обновить данные'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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`
|
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">
|
<div className="flex-shrink-0">
|
||||||
{styles.icon}
|
{styles.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { FaGithub } from 'react-icons/fa';
|
import { FaGithub } from 'react-icons/fa';
|
||||||
import logo from '../assets/logo.svg';
|
import logo from '../assets/logo.svg';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const { t, locale, setLocale } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-gray-800 text-white py-12">
|
<footer className="bg-gray-800 text-white py-12">
|
||||||
@@ -12,32 +16,32 @@ const Footer = () => {
|
|||||||
{/* About Section */}
|
{/* About Section */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex justify-center md:justify-start">
|
<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>
|
</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">
|
<p className="text-sm text-gray-400">
|
||||||
ospab.host - это надежный хостинг для ваших проектов. Мы предлагаем высокую производительность и круглосуточную поддержку.
|
{t('footer.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
<div>
|
<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">
|
<ul className="space-y-2 text-sm">
|
||||||
<li><Link to="/" 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="/tariffs" className="text-gray-400 hover:text-white transition-colors">Тарифы</Link></li>
|
<li><Link to={localePath('/tariffs')} className="text-gray-400 hover:text-white transition-colors">{t('nav.tariffs')}</Link></li>
|
||||||
<li><Link to="/about" className="text-gray-400 hover:text-white transition-colors">О нас</Link></li>
|
<li><Link to={localePath('/about')} className="text-gray-400 hover:text-white transition-colors">{t('nav.about')}</Link></li>
|
||||||
<li><Link to="/blog" className="text-gray-400 hover:text-white transition-colors">Блог</Link></li>
|
<li><Link to={localePath('/blog')} className="text-gray-400 hover:text-white transition-colors">{t('nav.blog')}</Link></li>
|
||||||
<li><Link to="/login" className="text-gray-400 hover:text-white transition-colors">Войти</Link></li>
|
<li><Link to={localePath('/login')} className="text-gray-400 hover:text-white transition-colors">{t('nav.login')}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legal Documents */}
|
{/* Legal Documents */}
|
||||||
<div>
|
<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">
|
<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={localePath('/privacy')} className="text-gray-400 hover:text-white transition-colors">{t('footer.privacy')}</Link></li>
|
||||||
<li><Link to="/terms" className="text-gray-400 hover:text-white transition-colors">Условия использования</Link></li>
|
<li><Link to={localePath('/terms')} className="text-gray-400 hover:text-white transition-colors">{t('footer.terms')}</Link></li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/ospab/ospabhost8.1"
|
href="https://github.com/ospab/ospabhost8.1"
|
||||||
@@ -53,10 +57,34 @@ const Footer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<p className="text-sm text-gray-400">
|
||||||
© {currentYear} ospab.host. Все права защищены.
|
© {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { useState } from 'react';
|
|||||||
import useAuth from '../context/useAuth';
|
import useAuth from '../context/useAuth';
|
||||||
import logo from '../assets/logo.svg';
|
import logo from '../assets/logo.svg';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { isLoggedIn, logout } = useAuth();
|
const { isLoggedIn, logout } = useAuth();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@@ -18,36 +22,36 @@ const Header = () => {
|
|||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link to="/" className="flex items-center">
|
<Link to={localePath('/')} className="flex items-center">
|
||||||
<img src={logo} alt="Логотип" className="h-10 lg:h-14 w-auto mr-2" width="56" height="56" />
|
<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>
|
<span className="font-mono text-xl lg:text-2xl text-gray-800 font-bold">ospab.host</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
<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={localePath('/tariffs')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.tariffs')}</Link>
|
||||||
<Link to="/blog" className="text-gray-600 hover:text-ospab-primary transition-colors">Блог</Link>
|
<Link to={localePath('/blog')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.blog')}</Link>
|
||||||
<Link to="/about" className="text-gray-600 hover:text-ospab-primary transition-colors">О нас</Link>
|
<Link to={localePath('/about')} className="text-gray-600 hover:text-ospab-primary transition-colors">{t('nav.about')}</Link>
|
||||||
{isLoggedIn ? (
|
{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 />
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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"
|
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>
|
</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
|
<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"
|
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>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -57,7 +61,7 @@ const Header = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
className="md:hidden p-2 text-gray-800"
|
className="md:hidden p-2 text-gray-800"
|
||||||
aria-label={isMobileMenuOpen ? "Закрыть меню" : "Открыть меню"}
|
aria-label={isMobileMenuOpen ? t('common.closeMenu') : t('common.openMenu')}
|
||||||
aria-expanded={isMobileMenuOpen}
|
aria-expanded={isMobileMenuOpen}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<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 && (
|
{isMobileMenuOpen && (
|
||||||
<div className="md:hidden mt-4 pb-4 space-y-2 border-t border-gray-200 pt-4">
|
<div className="md:hidden mt-4 pb-4 space-y-2 border-t border-gray-200 pt-4">
|
||||||
<Link
|
<Link
|
||||||
to="/tariffs"
|
to={localePath('/tariffs')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Тарифы
|
{t('nav.tariffs')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/blog"
|
to={localePath('/blog')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Блог
|
{t('nav.blog')}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/about"
|
to={localePath('/about')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
О нас
|
{t('nav.about')}
|
||||||
</Link>
|
</Link>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to={localePath('/dashboard')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Личный кабинет
|
{t('nav.dashboard')}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full text-left py-2 text-gray-600 hover:text-red-500 transition-colors"
|
className="w-full text-left py-2 text-gray-600 hover:text-red-500 transition-colors"
|
||||||
>
|
>
|
||||||
Выйти
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to={localePath('/login')}
|
||||||
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
className="block py-2 text-gray-600 hover:text-ospab-primary transition-colors"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Войти
|
{t('nav.login')}
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
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)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Зарегистрироваться
|
{t('nav.register')}
|
||||||
</Link>
|
</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 components;
|
||||||
@tailwind utilities;
|
@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 {
|
@keyframes modal-enter {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import ErrorPage from '../components/ErrorPage';
|
import ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function Unauthorized() {
|
export default function Unauthorized() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="401"
|
code="401"
|
||||||
title="Требуется авторизация"
|
title={t('errors.unauthorized')}
|
||||||
description="Для доступа к этому ресурсу необходимо войти в систему."
|
description={t('errors.unauthorizedDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function Forbidden() {
|
export default function Forbidden() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="403"
|
code="403"
|
||||||
title="Доступ запрещён"
|
title={t('errors.forbidden')}
|
||||||
description="У вас недостаточно прав для доступа к этой странице. Обратитесь к администратору, если считаете это ошибкой."
|
description={t('errors.forbiddenDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="404"
|
code="404"
|
||||||
title="Страница не найдена"
|
title={t('errors.notFound')}
|
||||||
description="К сожалению, запрашиваемая страница не существует или была перемещена."
|
description={t('errors.notFoundDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function ServerError() {
|
export default function ServerError() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="500"
|
code="500"
|
||||||
title="Ошибка сервера"
|
title={t('errors.serverError')}
|
||||||
description="На сервере произошла ошибка. Мы уже работаем над её устранением. Попробуйте обновить страницу или вернитесь позже."
|
description={t('errors.serverErrorDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function BadGateway() {
|
export default function BadGateway() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="502"
|
code="502"
|
||||||
title="Неверный шлюз"
|
title={t('errors.badGateway')}
|
||||||
description="Сервер получил недействительный ответ от вышестоящего сервера. Это временная проблема, попробуйте обновить страницу."
|
description={t('errors.badGatewayDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function ServiceUnavailable() {
|
export default function ServiceUnavailable() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="503"
|
code="503"
|
||||||
title="Сервис недоступен"
|
title={t('errors.serviceUnavailable')}
|
||||||
description="Сервер временно не может обработать запрос. Возможно, проводятся технические работы. Пожалуйста, попробуйте позже."
|
description={t('errors.serviceUnavailableDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 ErrorPage from '../components/ErrorPage';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
export default function GatewayTimeout() {
|
export default function GatewayTimeout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
code="504"
|
code="504"
|
||||||
title="Превышено время ожидания"
|
title={t('errors.gatewayTimeout')}
|
||||||
description="Сервер не дождался ответа от вышестоящего сервера. Это может быть вызвано временными проблемами с сетью."
|
description={t('errors.gatewayTimeoutDescription')}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 { FaRocket, FaUsers, FaShieldAlt, FaChartLine, FaHeart, FaServer, FaGithub } from 'react-icons/fa';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
const AboutPage = () => {
|
const AboutPage = () => {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -13,10 +19,10 @@ const AboutPage = () => {
|
|||||||
<div className="container mx-auto max-w-6xl relative z-10">
|
<div className="container mx-auto max-w-6xl relative z-10">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-5xl md:text-6xl font-extrabold mb-6 leading-tight">
|
<h1 className="text-5xl md:text-6xl font-extrabold mb-6 leading-tight">
|
||||||
История ospab.host
|
{isEn ? 'The Story of ospab.host' : 'История ospab.host'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl md:text-2xl text-blue-100 max-w-3xl mx-auto">
|
<p className="text-xl md:text-2xl text-blue-100 max-w-3xl mx-auto">
|
||||||
Первый дата-центр в Великом Новгороде.
|
{isEn ? 'The first data center in Veliky Novgorod.' : 'Первый дата-центр в Великом Новгороде.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,7 +36,7 @@ const AboutPage = () => {
|
|||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src="/me.jpg"
|
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"
|
className="w-48 h-48 md:w-56 md:h-56 rounded-2xl shadow-2xl border-4 border-ospab-primary object-cover"
|
||||||
width="224"
|
width="224"
|
||||||
height="224"
|
height="224"
|
||||||
@@ -39,23 +45,23 @@ const AboutPage = () => {
|
|||||||
|
|
||||||
<div className="flex-1 text-center md:text-left">
|
<div className="flex-1 text-center md:text-left">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-4xl font-bold text-gray-900 mb-3">Георгий</h2>
|
<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">Основатель и CEO</p>
|
<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">
|
<div className="flex flex-wrap justify-center md:justify-start gap-4 text-gray-600">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<FaUsers className="text-ospab-primary" />
|
<FaUsers className="text-ospab-primary" />
|
||||||
13 лет
|
{isEn ? '13 years old' : '13 лет'}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<FaServer className="text-ospab-primary" />
|
<FaServer className="text-ospab-primary" />
|
||||||
Великий Новгород
|
{isEn ? 'Veliky Novgorod' : 'Великий Новгород'}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/ospab/ospabhost8.1"
|
href="https://github.com/ospab/ospabhost8.1"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 hover:text-ospab-primary transition-colors"
|
className="flex items-center gap-2 hover:text-ospab-primary transition-colors"
|
||||||
title="Исходный код проекта"
|
title={isEn ? 'Project source code' : 'Исходный код проекта'}
|
||||||
>
|
>
|
||||||
<FaGithub className="text-ospab-primary" />
|
<FaGithub className="text-ospab-primary" />
|
||||||
GitHub
|
GitHub
|
||||||
@@ -64,9 +70,9 @@ const AboutPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-lg text-gray-700 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,42 +84,43 @@ const AboutPage = () => {
|
|||||||
<section className="py-20 px-4">
|
<section className="py-20 px-4">
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<h2 className="text-4xl md:text-5xl font-bold text-center text-gray-900 mb-12">
|
<h2 className="text-4xl md:text-5xl font-bold text-center text-gray-900 mb-12">
|
||||||
Наша история
|
{isEn ? 'Our Story' : 'Наша история'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<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">
|
<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">
|
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||||
<FaRocket className="text-ospab-primary" />
|
<FaRocket className="text-ospab-primary" />
|
||||||
Сентябрь 2025 — Начало пути
|
{isEn ? 'September 2025 — The Beginning' : 'Сентябрь 2025 — Начало пути'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-lg text-gray-700 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-r from-purple-50 to-white p-8 rounded-2xl border-l-4 border-ospab-accent shadow-lg">
|
<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">
|
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||||
<FaHeart className="text-ospab-accent" />
|
<FaHeart className="text-ospab-accent" />
|
||||||
Поддержка и развитие
|
{isEn ? 'Support and Development' : 'Поддержка и развитие'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-lg text-gray-700 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-r from-green-50 to-white p-8 rounded-2xl border-l-4 border-green-500 shadow-lg">
|
<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">
|
<h3 className="text-2xl font-bold text-gray-900 mb-4 flex items-center gap-3">
|
||||||
<FaChartLine className="text-green-500" />
|
<FaChartLine className="text-green-500" />
|
||||||
Настоящее и будущее
|
{isEn ? 'Present and Future' : 'Настоящее и будущее'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-lg text-gray-700 leading-relaxed">
|
<p className="text-lg text-gray-700 leading-relaxed">
|
||||||
Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД.
|
{isEn
|
||||||
ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.
|
? "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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +132,12 @@ const AboutPage = () => {
|
|||||||
<div className="container mx-auto max-w-6xl">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||||
Наша миссия
|
{isEn ? 'Our Mission' : 'Наша миссия'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<FaServer className="text-3xl text-ospab-primary" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 leading-relaxed">
|
||||||
Используем новейшее оборудование и программное обеспечение для максимальной производительности
|
{isEn
|
||||||
|
? 'We use the latest equipment and software for maximum performance'
|
||||||
|
: 'Используем новейшее оборудование и программное обеспечение для максимальной производительности'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<FaShieldAlt className="text-3xl text-ospab-accent" />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<FaUsers className="text-3xl text-green-500" />
|
||||||
</div>
|
</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">
|
<p className="text-gray-600 leading-relaxed">
|
||||||
Каждый клиент получает персональное внимание и помощь от основателя
|
{isEn
|
||||||
|
? 'Every customer receives personal attention and help from the founder'
|
||||||
|
: 'Каждый клиент получает персональное внимание и помощь от основателя'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +186,7 @@ const AboutPage = () => {
|
|||||||
<div className="container mx-auto max-w-6xl">
|
<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">
|
<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">
|
<h2 className="text-4xl md:text-5xl font-bold text-center mb-12">
|
||||||
Почему выбирают ospab.host?
|
{isEn ? 'Why choose ospab.host?' : 'Почему выбирают ospab.host?'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
<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>
|
<span className="text-white font-bold">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-2">Первый ЦОД в городе</h4>
|
<h4 className="font-bold text-lg mb-2">{isEn ? 'First data center in the city' : 'Первый ЦОД в городе'}</h4>
|
||||||
<p className="text-blue-100">Мы создаём историю Великого Новгорода</p>
|
<p className="text-blue-100">{isEn ? "We're making Veliky Novgorod history" : 'Мы создаём историю Великого Новгорода'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -190,8 +205,8 @@ const AboutPage = () => {
|
|||||||
<span className="text-white font-bold">✓</span>
|
<span className="text-white font-bold">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-2">Доступные тарифы</h4>
|
<h4 className="font-bold text-lg mb-2">{isEn ? 'Affordable pricing' : 'Доступные тарифы'}</h4>
|
||||||
<p className="text-blue-100">Качественный хостинг для всех без переплат</p>
|
<p className="text-blue-100">{isEn ? 'Quality hosting for everyone without overpaying' : 'Качественный хостинг для всех без переплат'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,8 +215,8 @@ const AboutPage = () => {
|
|||||||
<span className="text-white font-bold">✓</span>
|
<span className="text-white font-bold">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-2">Быстрая поддержка</h4>
|
<h4 className="font-bold text-lg mb-2">{isEn ? 'Fast support' : 'Быстрая поддержка'}</h4>
|
||||||
<p className="text-blue-100">Ответим на вопросы в любое время</p>
|
<p className="text-blue-100">{isEn ? "We'll answer questions anytime" : 'Ответим на вопросы в любое время'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,8 +225,8 @@ const AboutPage = () => {
|
|||||||
<span className="text-white font-bold">✓</span>
|
<span className="text-white font-bold">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-2">Прозрачность</h4>
|
<h4 className="font-bold text-lg mb-2">{isEn ? 'Transparency' : 'Прозрачность'}</h4>
|
||||||
<p className="text-blue-100">Честно о возможностях и ограничениях</p>
|
<p className="text-blue-100">{isEn ? 'Honest about capabilities and limitations' : 'Честно о возможностях и ограничениях'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,8 +235,8 @@ const AboutPage = () => {
|
|||||||
<span className="text-white font-bold">✓</span>
|
<span className="text-white font-bold">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-2">Современная инфраструктура</h4>
|
<h4 className="font-bold text-lg mb-2">{isEn ? 'Modern infrastructure' : 'Современная инфраструктура'}</h4>
|
||||||
<p className="text-blue-100">Актуальное ПО и оборудование</p>
|
<p className="text-blue-100">{isEn ? 'Up-to-date software and equipment' : 'Актуальное ПО и оборудование'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,8 +245,8 @@ const AboutPage = () => {
|
|||||||
<span className="text-white font-bold">✓</span>
|
<span className="text-white font-bold">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-bold text-lg mb-2">Мечта становится реальностью</h4>
|
<h4 className="font-bold text-lg mb-2">{isEn ? 'A dream becoming reality' : 'Мечта становится реальностью'}</h4>
|
||||||
<p className="text-blue-100">История, которой можно гордиться</p>
|
<p className="text-blue-100">{isEn ? 'A story to be proud of' : 'История, которой можно гордиться'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,7 +263,7 @@ const AboutPage = () => {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
Исходный код на GitHub
|
{isEn ? 'Source code on GitHub' : 'Исходный код на GitHub'}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,23 +277,25 @@ const AboutPage = () => {
|
|||||||
<section className="py-20 px-4 bg-gray-50">
|
<section className="py-20 px-4 bg-gray-50">
|
||||||
<div className="container mx-auto max-w-4xl text-center">
|
<div className="container mx-auto max-w-4xl text-center">
|
||||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||||
Станьте частью истории
|
{isEn ? 'Become part of history' : 'Станьте частью истории'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 mb-10">
|
<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>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<a
|
<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"
|
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>
|
||||||
<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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { API_URL } from '../config/api';
|
import { API_URL } from '../config/api';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
interface Post {
|
interface Post {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,6 +27,8 @@ const Blog: React.FC = () => {
|
|||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPosts();
|
loadPosts();
|
||||||
@@ -37,8 +41,8 @@ const Blog: React.FC = () => {
|
|||||||
setPosts(response.data.data);
|
setPosts(response.data.data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка загрузки постов:', err);
|
console.error('Error loading posts:', err);
|
||||||
setError('Не удалось загрузить статьи');
|
setError(locale === 'en' ? 'Failed to load articles' : 'Не удалось загрузить статьи');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,7 @@ const Blog: React.FC = () => {
|
|||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('ru-RU', {
|
return date.toLocaleDateString(locale === 'en' ? 'en-US' : 'ru-RU', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -56,7 +60,7 @@ const Blog: React.FC = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,9 +70,9 @@ const Blog: React.FC = () => {
|
|||||||
<div className="container mx-auto px-4 max-w-6xl">
|
<div className="container mx-auto px-4 max-w-6xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-12">
|
<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">
|
<p className="text-xl text-gray-600">
|
||||||
Новости, статьи и полезные материалы о хостинге
|
{t('blog.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,14 +86,14 @@ const Blog: React.FC = () => {
|
|||||||
{/* Posts Grid */}
|
{/* Posts Grid */}
|
||||||
{posts.length === 0 ? (
|
{posts.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.id}
|
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"
|
className="bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow overflow-hidden group"
|
||||||
>
|
>
|
||||||
{/* Cover Image */}
|
{/* Cover Image */}
|
||||||
@@ -103,7 +107,7 @@ const Blog: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-48 bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -122,9 +126,8 @@ const Blog: React.FC = () => {
|
|||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span>Автор: {post.author.username}</span>
|
<span className="truncate max-w-[150px]" title={post.author.username}>{t('blog.author')}: {post.author.username}</span>
|
||||||
<span>Просмотров: {post.views}</span>
|
<span>{locale === 'en' ? 'Views' : 'Просмотров'}: {post.views}</span>
|
||||||
<span>Комментариев: {post._count.comments}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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-6 text-gray-500 mb-8 pb-6 border-b">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Автор:</span>
|
<span>Автор:</span>
|
||||||
<span>{post.author.username}</span>
|
<span className="truncate max-w-[150px]" title={post.author.username}>{post.author.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>Дата:</span>
|
<span>Дата:</span>
|
||||||
@@ -257,7 +257,7 @@ const BlogPost: React.FC = () => {
|
|||||||
{post.comments.map((comment) => (
|
{post.comments.map((comment) => (
|
||||||
<div key={comment.id} className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
<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">
|
<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}
|
{comment.user ? comment.user.username : comment.authorName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
|
|||||||
@@ -769,93 +769,90 @@ const AdminPanel = () => {
|
|||||||
<p className="font-semibold">{usersError}</p>
|
<p className="font-semibold">{usersError}</p>
|
||||||
</div>
|
</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">
|
{/* Mobile: stacked cards */}
|
||||||
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
<div className="space-y-4 md:hidden">
|
||||||
<tr>
|
{filteredUsers.length === 0 ? (
|
||||||
<th className="px-4 py-3 text-left">Пользователь</th>
|
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-500">Пользователи не найдены.</div>
|
||||||
<th className="px-4 py-3 text-left">Email</th>
|
) : (
|
||||||
<th className="px-4 py-3 text-left">Баланс</th>
|
filteredUsers.map((user) => (
|
||||||
<th className="px-4 py-3 text-left">Сервера</th>
|
<div key={user.id} className="rounded-lg border border-gray-200 bg-white p-4 shadow">
|
||||||
<th className="px-4 py-3 text-left">Тикеты</th>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<th className="px-4 py-3 text-left">Роли</th>
|
<div>
|
||||||
<th className="px-4 py-3 text-right">Действия</th>
|
<div className="font-medium text-gray-900">{user.username}</div>
|
||||||
</tr>
|
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
||||||
</thead>
|
<div className="text-sm text-gray-500 mt-2">{user.email}</div>
|
||||||
<tbody className="divide-y divide-gray-100 text-gray-700">
|
</div>
|
||||||
{filteredUsers.length === 0 ? (
|
<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>
|
<tr>
|
||||||
<td className="px-4 py-6 text-center text-gray-500" colSpan={7}>
|
<th className="px-4 py-3 text-left">Пользователь</th>
|
||||||
Пользователи не найдены.
|
<th className="px-4 py-3 text-left">Email</th>
|
||||||
</td>
|
<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>
|
</tr>
|
||||||
) : (
|
</thead>
|
||||||
filteredUsers.map((user) => {
|
<tbody className="divide-y divide-gray-100 text-gray-700">
|
||||||
const busy = roleUpdating[user.id] || deletingUserId === user.id;
|
{filteredUsers.length === 0 ? (
|
||||||
return (
|
<tr>
|
||||||
<tr key={user.id} className="hover:bg-gray-50">
|
<td className="px-4 py-6 text-center text-gray-500" colSpan={7}>Пользователи не найдены.</td>
|
||||||
<td className="px-4 py-3">
|
</tr>
|
||||||
<div className="font-medium text-gray-900">{user.username}</div>
|
) : (
|
||||||
<div className="text-xs text-gray-500">ID {user.id} · {formatDateTime(user.createdAt)}</div>
|
filteredUsers.map((user) => {
|
||||||
</td>
|
const busy = roleUpdating[user.id] || deletingUserId === user.id;
|
||||||
<td className="px-4 py-3 text-gray-600">{user.email}</td>
|
return (
|
||||||
<td
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
className={`px-4 py-3 font-medium ${
|
<td className="px-4 py-3">
|
||||||
user.balance >= 0 ? 'text-gray-900' : 'text-red-600'
|
<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>
|
||||||
{formatCurrency(user.balance)}
|
<td className="px-4 py-3 text-gray-600">{user.email}</td>
|
||||||
</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.buckets ?? 0)}</td>
|
||||||
<td className="px-4 py-3">{formatNumber(user._count.tickets ?? 0)}</td>
|
<td className="px-4 py-3">{formatNumber(user._count.tickets ?? 0)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
<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 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>
|
||||||
</span>
|
</div>
|
||||||
<span className={`rounded px-2 py-0.5 font-medium ${user.operator ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
</td>
|
||||||
Оператор
|
<td className="px-4 py-3">
|
||||||
</span>
|
<div className="flex justify-end gap-2 text-xs font-medium">
|
||||||
</div>
|
<button onClick={() => void openUserDetails(user.id)} className="text-blue-600 hover:text-blue-800">Подробнее</button>
|
||||||
</td>
|
<button onClick={() => void handleToggleAdmin(user)} disabled={busy} className="text-purple-600 hover:text-purple-800 disabled:opacity-50">{user.isAdmin ? 'Снять админа' : 'Дать админа'}</button>
|
||||||
<td className="px-4 py-3">
|
<button onClick={() => void handleToggleOperator(user)} disabled={busy} className="text-indigo-600 hover:text-indigo-800 disabled:opacity-50">{user.operator ? 'Снять оператора' : 'Дать оператора'}</button>
|
||||||
<div className="flex justify-end gap-2 text-xs font-medium">
|
<button onClick={() => void handleDeleteUser(user)} disabled={busy} className="text-red-600 hover:text-red-800 disabled:opacity-50">{deletingUserId === user.id ? 'Удаляем...' : 'Удалить'}</button>
|
||||||
<button
|
</div>
|
||||||
onClick={() => void openUserDetails(user.id)}
|
</td>
|
||||||
className="text-blue-600 hover:text-blue-800"
|
</tr>
|
||||||
>
|
);
|
||||||
Подробнее
|
})
|
||||||
</button>
|
)}
|
||||||
<button
|
</tbody>
|
||||||
onClick={() => void handleToggleAdmin(user)}
|
</table>
|
||||||
disabled={busy}
|
</div>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { API_URL } from '../../config/api';
|
import { API_URL } from '../../config/api';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
||||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
||||||
@@ -15,6 +16,9 @@ interface Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Billing = () => {
|
const Billing = () => {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
const [amount, setAmount] = useState<number>(0);
|
const [amount, setAmount] = useState<number>(0);
|
||||||
const [balance, setBalance] = useState<number>(0);
|
const [balance, setBalance] = useState<number>(0);
|
||||||
const [checks, setChecks] = useState<Check[]>([]);
|
const [checks, setChecks] = useState<Check[]>([]);
|
||||||
@@ -136,11 +140,11 @@ const Billing = () => {
|
|||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
return 'Зачислено';
|
return isEn ? 'Approved' : 'Зачислено';
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
return 'Отклонено';
|
return isEn ? 'Rejected' : 'Отклонено';
|
||||||
default:
|
default:
|
||||||
return 'На проверке';
|
return isEn ? 'Pending' : 'На проверке';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,7 +161,9 @@ const Billing = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl max-w-4xl mx-auto">
|
<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 && (
|
{message && (
|
||||||
@@ -172,7 +178,7 @@ const Billing = () => {
|
|||||||
|
|
||||||
{/* Текущий баланс */}
|
{/* Текущий баланс */}
|
||||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl mb-6">
|
<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>
|
<p className="text-3xl lg:text-4xl font-extrabold text-ospab-primary">{balance.toFixed(2)} ₽</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,7 +187,7 @@ const Billing = () => {
|
|||||||
{/* Ввод суммы */}
|
{/* Ввод суммы */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="amount" className="block text-gray-700 font-semibold mb-2">
|
<label htmlFor="amount" className="block text-gray-700 font-semibold mb-2">
|
||||||
Сумма пополнения (₽)
|
{isEn ? 'Top-up amount (₽)' : 'Сумма пополнения (₽)'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -190,13 +196,13 @@ const Billing = () => {
|
|||||||
onChange={(e) => setAmount(Number(e.target.value))}
|
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"
|
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"
|
min="1"
|
||||||
placeholder="Введите сумму"
|
placeholder={isEn ? 'Enter amount' : 'Введите сумму'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Быстрые суммы */}
|
{/* Быстрые суммы */}
|
||||||
<div className="mb-6">
|
<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">
|
<div className="grid grid-cols-3 md:grid-cols-5 gap-2">
|
||||||
{quickAmounts.map((quickAmount) => (
|
{quickAmounts.map((quickAmount) => (
|
||||||
<button
|
<button
|
||||||
@@ -219,18 +225,18 @@ const Billing = () => {
|
|||||||
disabled={amount <= 0}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{/* Инструкция */}
|
{/* Инструкция */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6">
|
<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">
|
<ol className="text-sm text-blue-700 space-y-1 list-decimal list-inside">
|
||||||
<li>Переведите <strong>₽{amount}</strong> по СБП или на карту</li>
|
<li>{isEn ? <>Transfer <strong>₽{amount}</strong> via SBP or to card</> : <>Переведите <strong>₽{amount}</strong> по СБП или на карту</>}</li>
|
||||||
<li>Сохраните чек об оплате</li>
|
<li>{isEn ? 'Save the payment receipt' : 'Сохраните чек об оплате'}</li>
|
||||||
<li>Загрузите чек ниже для проверки</li>
|
<li>{isEn ? 'Upload the receipt below for verification' : 'Загрузите чек ниже для проверки'}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -238,18 +244,18 @@ const Billing = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
{/* QR СБП */}
|
{/* QR СБП */}
|
||||||
<div className="bg-gray-100 p-4 rounded-xl">
|
<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">
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||||
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE'} size={200} />
|
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE'} size={200} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
<p className="mt-3 text-xs text-gray-600 text-center">
|
||||||
Отсканируйте QR-код в приложении банка
|
{isEn ? 'Scan QR code in your bank app' : 'Отсканируйте QR-код в приложении банка'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Номер карты */}
|
{/* Номер карты */}
|
||||||
<div className="bg-gray-100 p-4 rounded-xl">
|
<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">
|
<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'}
|
{cardNumber || '0000 0000 0000 0000'}
|
||||||
</p>
|
</p>
|
||||||
@@ -257,35 +263,35 @@ const Billing = () => {
|
|||||||
onClick={handleCopyCard}
|
onClick={handleCopyCard}
|
||||||
className="w-full px-4 py-2 rounded-lg text-white font-semibold bg-gray-700 hover:bg-gray-800 transition"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Загрузка чека */}
|
{/* Загрузка чека */}
|
||||||
<div className="bg-gray-50 border-2 border-dashed border-gray-300 rounded-xl p-6 mb-4">
|
<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 ? (
|
{checkFile ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-700 mb-2">
|
<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>
|
||||||
<p className="text-sm text-gray-500 mb-3">
|
<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>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCheckFile(null)}
|
onClick={() => setCheckFile(null)}
|
||||||
className="px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400 transition"
|
className="px-4 py-2 bg-gray-300 rounded-lg hover:bg-gray-400 transition"
|
||||||
>
|
>
|
||||||
Удалить
|
{isEn ? 'Remove' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckUpload}
|
onClick={handleCheckUpload}
|
||||||
disabled={uploadLoading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +299,7 @@ const Billing = () => {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-gray-600 mb-2">
|
<p className="text-gray-600 mb-2">
|
||||||
<label className="text-ospab-primary cursor-pointer hover:underline font-semibold">
|
<label className="text-ospab-primary cursor-pointer hover:underline font-semibold">
|
||||||
Нажмите, чтобы выбрать файл
|
{isEn ? 'Click to select a file' : 'Нажмите, чтобы выбрать файл'}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,application/pdf"
|
accept="image/*,application/pdf"
|
||||||
@@ -302,7 +308,7 @@ const Billing = () => {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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"
|
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300 transition"
|
||||||
>
|
>
|
||||||
Изменить сумму
|
{isEn ? 'Change amount' : 'Изменить сумму'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* История чеков */}
|
{/* История чеков */}
|
||||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
<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 ? (
|
{checks.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{checks.map((check) => (
|
{checks.map((check) => (
|
||||||
@@ -332,7 +338,7 @@ const Billing = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-800">{check.amount} ₽</p>
|
<p className="font-semibold text-gray-800">{check.amount} ₽</p>
|
||||||
<p className="text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -362,18 +368,18 @@ const Billing = () => {
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Ошибка загрузки чека:', err);
|
console.error('Ошибка загрузки чека:', err);
|
||||||
showMessage('Не удалось загрузить чек', 'error');
|
showMessage(isEn ? 'Failed to load receipt' : 'Не удалось загрузить чек', 'error');
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Чек
|
{isEn ? 'Receipt' : 'Чек'}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type CartPayload = {
|
|||||||
plan: CheckoutPlan;
|
plan: CheckoutPlan;
|
||||||
price: number;
|
price: number;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
originalPrice?: number | null;
|
||||||
|
promoDiscount?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/;
|
const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/;
|
||||||
@@ -187,14 +189,33 @@ const Checkout: React.FC = () => {
|
|||||||
|
|
||||||
const handleApplyPromo = useCallback(async () => {
|
const handleApplyPromo = useCallback(async () => {
|
||||||
if (!cart) return;
|
if (!cart) return;
|
||||||
|
if (!promoCode.trim()) {
|
||||||
|
setPromoError('Введите промокод');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPromoError(null);
|
setPromoError(null);
|
||||||
try {
|
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;
|
const updated = res.data?.cart;
|
||||||
if (updated) setCart(updated as CartPayload);
|
if (updated) setCart(updated as CartPayload);
|
||||||
setPromoApplied(true);
|
setPromoApplied(true);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
setPromoError(err instanceof Error ? err.message : 'Не удалось применить промокод');
|
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]);
|
}, [cart, promoCode]);
|
||||||
|
|
||||||
@@ -287,14 +308,66 @@ const Checkout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full md:w-1/3 mt-4 md:mt-0">
|
{/* Promo Code Section */}
|
||||||
<label className="block text-sm font-medium text-gray-700">Промокод</label>
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<input value={promoCode} onChange={(e) => setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" />
|
<FiShoppingCart className="text-ospab-primary text-xl" />
|
||||||
<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>
|
<h3 className="text-lg font-semibold text-gray-900">Промокод</h3>
|
||||||
</div>
|
</div>
|
||||||
{promoError && <div className="text-red-500 text-sm mt-1">{promoError}</div>}
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
{promoApplied && !promoError && <div className="text-green-600 text-sm mt-1">Промокод применён</div>}
|
<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>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||||
@@ -481,12 +554,72 @@ const Checkout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plan && (
|
{plan && (
|
||||||
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
<>
|
||||||
{balanceAfterPayment >= 0
|
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||||
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
{balanceAfterPayment >= 0
|
||||||
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
|
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
||||||
</p>
|
: `Не хватает: ${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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'
|
|||||||
import { isAxiosError } from 'axios';
|
import { isAxiosError } from 'axios';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import AuthContext from '../../context/authcontext';
|
import AuthContext from '../../context/authcontext';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
// Импортируем компоненты для вкладок
|
// Импортируем компоненты для вкладок
|
||||||
import Summary from './summary';
|
import Summary from './summary';
|
||||||
@@ -29,6 +30,8 @@ const Dashboard = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { userData, setUserData, logout, refreshUser, isInitialized } = useContext(AuthContext);
|
const { userData, setUserData, logout, refreshUser, isInitialized } = useContext(AuthContext);
|
||||||
|
const { locale, setLocale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('summary');
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
|
|
||||||
@@ -99,27 +102,27 @@ const Dashboard = () => {
|
|||||||
if (!isInitialized || loading) {
|
if (!isInitialized || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вкладки для сайдбара
|
// Вкладки для сайдбара
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'summary', label: 'Сводка', to: '/dashboard' },
|
{ key: 'summary', label: isEn ? 'Summary' : 'Сводка', to: '/dashboard' },
|
||||||
{ key: 'storage', label: 'Хранилище', to: '/dashboard/storage' },
|
{ key: 'storage', label: isEn ? 'Storage' : 'Хранилище', to: '/dashboard/storage' },
|
||||||
{ key: 'tickets', label: 'Тикеты', to: '/dashboard/tickets' },
|
{ key: 'tickets', label: isEn ? 'Tickets' : 'Тикеты', to: '/dashboard/tickets' },
|
||||||
{ key: 'billing', label: 'Баланс', to: '/dashboard/billing' },
|
{ key: 'billing', label: isEn ? 'Balance' : 'Баланс', to: '/dashboard/billing' },
|
||||||
{ key: 'settings', label: 'Настройки', to: '/dashboard/settings' },
|
{ key: 'settings', label: isEn ? 'Settings' : 'Настройки', to: '/dashboard/settings' },
|
||||||
{ key: 'notifications', label: 'Уведомления', to: '/dashboard/notifications' },
|
{ key: 'notifications', label: isEn ? 'Notifications' : 'Уведомления', to: '/dashboard/notifications' },
|
||||||
];
|
];
|
||||||
const adminTabs = [
|
const adminTabs = [
|
||||||
{ key: 'checkverification', label: 'Проверка чеков', to: '/dashboard/checkverification' },
|
{ key: 'checkverification', label: isEn ? 'Check Verification' : 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const superAdminTabs = [
|
const superAdminTabs = [
|
||||||
{ key: 'admin', label: 'Админ-панель', to: '/dashboard/admin' },
|
{ key: 'admin', label: isEn ? 'Admin Panel' : 'Админ-панель', to: '/dashboard/admin' },
|
||||||
{ key: 'blogadmin', label: 'Блог', to: '/dashboard/blogadmin' },
|
{ key: 'blogadmin', label: isEn ? 'Blog' : 'Блог', to: '/dashboard/blogadmin' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,22 +150,22 @@ const Dashboard = () => {
|
|||||||
`}>
|
`}>
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-gray-200">
|
||||||
<h2 className="text-xl font-bold text-gray-800 break-words">
|
<h2 className="text-xl font-bold text-gray-800 break-words">
|
||||||
Привет, {userData?.user?.username || 'Гость'}!
|
{isEn ? 'Hello' : 'Привет'}, {userData?.user?.username || (isEn ? 'Guest' : 'Гость')}!
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
{isOperator && (
|
{isOperator && (
|
||||||
<span className="inline-block px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
|
<span className="inline-block px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
|
||||||
Оператор
|
{isEn ? 'Operator' : 'Оператор'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full">
|
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full">
|
||||||
Супер Админ
|
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-6 overflow-y-auto">
|
<nav className="flex-1 p-6 overflow-y-auto">
|
||||||
@@ -183,7 +186,7 @@ const Dashboard = () => {
|
|||||||
{isOperator && (
|
{isOperator && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
<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">
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-4">
|
||||||
Админ панель
|
{isEn ? 'Admin Panel' : 'Админ панель'}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{adminTabs.map(tab => (
|
{adminTabs.map(tab => (
|
||||||
@@ -204,7 +207,7 @@ const Dashboard = () => {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
<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">
|
<p className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-3 px-4">
|
||||||
Супер Админ
|
{isEn ? 'Super Admin' : 'Супер Админ'}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{superAdminTabs.map(tab => (
|
{superAdminTabs.map(tab => (
|
||||||
@@ -223,9 +226,32 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</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>© 2025 ospab.host</p>
|
||||||
<p className="mt-1">Версия 1.0.0</p>
|
<p className="mt-1">{isEn ? 'Version' : 'Версия'} 1.0.0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,10 +267,10 @@ const Dashboard = () => {
|
|||||||
<div className="flex-1 flex flex-col w-full lg:w-auto">
|
<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">
|
<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">
|
<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>
|
</h1>
|
||||||
<p className="text-xs lg:text-sm text-gray-600 mt-1">
|
<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',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
import {
|
import {
|
||||||
getNotifications,
|
getNotifications,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
@@ -16,6 +17,8 @@ const NotificationsPage = () => {
|
|||||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||||
const [pushEnabled, setPushEnabled] = useState(false);
|
const [pushEnabled, setPushEnabled] = useState(false);
|
||||||
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
|
const [pushPermission, setPushPermission] = useState<NotificationPermission>('default');
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
const checkPushPermission = () => {
|
const checkPushPermission = () => {
|
||||||
if ('Notification' in window) {
|
if ('Notification' in window) {
|
||||||
@@ -83,7 +86,7 @@ const NotificationsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAllRead = async () => {
|
const handleDeleteAllRead = async () => {
|
||||||
if (!window.confirm('Удалить все прочитанные уведомления?')) return;
|
if (!window.confirm(isEn ? 'Delete all read notifications?' : 'Удалить все прочитанные уведомления?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAllRead();
|
await deleteAllRead();
|
||||||
@@ -98,9 +101,9 @@ const NotificationsPage = () => {
|
|||||||
if (success) {
|
if (success) {
|
||||||
setPushEnabled(true);
|
setPushEnabled(true);
|
||||||
setPushPermission('granted');
|
setPushPermission('granted');
|
||||||
alert('Push-уведомления успешно подключены!');
|
alert(isEn ? 'Push notifications enabled!' : 'Push-уведомления успешно подключены!');
|
||||||
} else {
|
} else {
|
||||||
alert('Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
|
alert(isEn ? 'Failed to enable push notifications. Check your browser permissions.' : 'Не удалось подключить Push-уведомления. Проверьте разрешения браузера.');
|
||||||
// Обновляем состояние на случай, если пользователь отклонил
|
// Обновляем состояние на случай, если пользователь отклонил
|
||||||
checkPushPermission();
|
checkPushPermission();
|
||||||
}
|
}
|
||||||
@@ -108,7 +111,7 @@ const NotificationsPage = () => {
|
|||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleString('ru-RU', {
|
return date.toLocaleString(isEn ? 'en-US' : 'ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -125,11 +128,16 @@ const NotificationsPage = () => {
|
|||||||
const weekAgo = new Date(today);
|
const weekAgo = new Date(today);
|
||||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
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[]> = {
|
const groups: Record<string, Notification[]> = {
|
||||||
'Сегодня': [],
|
[todayLabel]: [],
|
||||||
'Вчера': [],
|
[yesterdayLabel]: [],
|
||||||
'За последние 7 дней': [],
|
[weekLabel]: [],
|
||||||
'Ранее': []
|
[earlierLabel]: []
|
||||||
};
|
};
|
||||||
|
|
||||||
notifications.forEach((notification) => {
|
notifications.forEach((notification) => {
|
||||||
@@ -137,13 +145,13 @@ const NotificationsPage = () => {
|
|||||||
const notifDay = new Date(notifDate.getFullYear(), notifDate.getMonth(), notifDate.getDate());
|
const notifDay = new Date(notifDate.getFullYear(), notifDate.getMonth(), notifDate.getDate());
|
||||||
|
|
||||||
if (notifDay.getTime() === today.getTime()) {
|
if (notifDay.getTime() === today.getTime()) {
|
||||||
groups['Сегодня'].push(notification);
|
groups[todayLabel].push(notification);
|
||||||
} else if (notifDay.getTime() === yesterday.getTime()) {
|
} else if (notifDay.getTime() === yesterday.getTime()) {
|
||||||
groups['Вчера'].push(notification);
|
groups[yesterdayLabel].push(notification);
|
||||||
} else if (notifDate >= weekAgo) {
|
} else if (notifDate >= weekAgo) {
|
||||||
groups['За последние 7 дней'].push(notification);
|
groups[weekLabel].push(notification);
|
||||||
} else {
|
} else {
|
||||||
groups['Ранее'].push(notification);
|
groups[earlierLabel].push(notification);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +164,7 @@ const NotificationsPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="p-4 lg:p-8">
|
<div className="p-4 lg:p-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<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">
|
<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'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Все ({notifications.length})
|
{isEn ? 'All' : 'Все'} ({notifications.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilter('unread')}
|
onClick={() => setFilter('unread')}
|
||||||
@@ -181,7 +189,7 @@ const NotificationsPage = () => {
|
|||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Непрочитанные ({unreadCount})
|
{isEn ? 'Unread' : 'Непрочитанные'} ({unreadCount})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,7 +200,7 @@ const NotificationsPage = () => {
|
|||||||
onClick={handleMarkAllAsRead}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{notifications.some((n) => n.isRead) && (
|
{notifications.some((n) => n.isRead) && (
|
||||||
@@ -200,7 +208,7 @@ const NotificationsPage = () => {
|
|||||||
onClick={handleDeleteAllRead}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -214,16 +222,16 @@ const NotificationsPage = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
<h3 className="text-sm font-semibold text-blue-900 mb-1">
|
||||||
Подключите Push-уведомления
|
{isEn ? 'Enable Push Notifications' : 'Подключите Push-уведомления'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-blue-700 mb-3">
|
<p className="text-sm text-blue-700 mb-3">
|
||||||
Получайте мгновенные уведомления на компьютер или телефон при важных событиях
|
{isEn ? 'Get instant notifications on your device for important events' : 'Получайте мгновенные уведомления на компьютер или телефон при важных событиях'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleEnablePush}
|
onClick={handleEnablePush}
|
||||||
className="px-4 py-2 rounded-md text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,11 +248,13 @@ const NotificationsPage = () => {
|
|||||||
Push-уведомления заблокированы
|
Push-уведомления заблокированы
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-red-700">
|
<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>
|
||||||
<p className="text-xs text-red-600 mt-2">
|
<p className="text-xs text-red-600 mt-2">
|
||||||
Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить<br/>
|
{isEn
|
||||||
Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки
|
? <>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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<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>
|
</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">
|
<p className="text-gray-600">
|
||||||
{filter === 'unread' ? 'Все уведомления прочитаны' : 'У вас пока нет уведомлений'}
|
{filter === 'unread'
|
||||||
|
? (isEn ? 'All notifications are read' : 'Все уведомления прочитаны')
|
||||||
|
: (isEn ? 'You have no notifications yet' : 'У вас пока нет уведомлений')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, createContext, useContext } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { API_URL } from '../../config/api';
|
import { API_URL } from '../../config/api';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
import {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
@@ -30,10 +31,16 @@ import {
|
|||||||
|
|
||||||
type TabType = 'profile' | 'security' | 'notifications' | 'api' | 'ssh' | 'delete';
|
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 SettingsPage = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
const [activeTab, setActiveTab] = useState<TabType>('profile');
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
@@ -60,21 +67,22 @@ const SettingsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'profile' as TabType, label: 'Профиль' },
|
{ id: 'profile' as TabType, label: isEn ? 'Profile' : 'Профиль' },
|
||||||
{ id: 'security' as TabType, label: 'Безопасность' },
|
{ id: 'security' as TabType, label: isEn ? 'Security' : 'Безопасность' },
|
||||||
{ id: 'notifications' as TabType, label: 'Уведомления' },
|
{ id: 'notifications' as TabType, label: isEn ? 'Notifications' : 'Уведомления' },
|
||||||
{ id: 'api' as TabType, label: 'API ключи' },
|
{ id: 'api' as TabType, label: isEn ? 'API Keys' : 'API ключи' },
|
||||||
{ id: 'ssh' as TabType, label: 'SSH ключи' },
|
{ id: 'ssh' as TabType, label: isEn ? 'SSH Keys' : 'SSH ключи' },
|
||||||
{ id: 'delete' as TabType, label: 'Удаление' },
|
{ id: 'delete' as TabType, label: isEn ? 'Delete' : 'Удаление' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SettingsLangContext.Provider value={isEn}>
|
||||||
<div className="p-4 lg:p-8">
|
<div className="p-4 lg:p-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Настройки аккаунта</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Account Settings' : 'Настройки аккаунта'}</h1>
|
||||||
<p className="text-gray-600 mt-2">Управление профилем, безопасностью и интеграциями</p>
|
<p className="text-gray-600 mt-2">{isEn ? 'Manage profile, security and integrations' : 'Управление профилем, безопасностью и интеграциями'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
@@ -113,11 +121,13 @@ const SettingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsLangContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============ ПРОФИЛЬ ============
|
// ============ ПРОФИЛЬ ============
|
||||||
const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpdate: () => void }) => {
|
const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpdate: () => void }) => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [username, setUsername] = useState(profile?.username || '');
|
const [username, setUsername] = useState(profile?.username || '');
|
||||||
const [email, setEmail] = useState(profile?.email || '');
|
const [email, setEmail] = useState(profile?.email || '');
|
||||||
const [phoneNumber, setPhoneNumber] = useState(profile?.profile?.phoneNumber || '');
|
const [phoneNumber, setPhoneNumber] = useState(profile?.profile?.phoneNumber || '');
|
||||||
@@ -145,29 +155,29 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await uploadAvatar(avatarFile);
|
await uploadAvatar(avatarFile);
|
||||||
alert('Аватар загружен!');
|
alert(isEn ? 'Avatar uploaded!' : 'Аватар загружен!');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
setAvatarFile(null);
|
setAvatarFile(null);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки аватара:', error);
|
console.error('Ошибка загрузки аватара:', error);
|
||||||
alert('Ошибка загрузки аватара');
|
alert(isEn ? 'Error uploading avatar' : 'Ошибка загрузки аватара');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAvatar = async () => {
|
const handleDeleteAvatar = async () => {
|
||||||
if (!confirm('Удалить аватар?')) return;
|
if (!confirm(isEn ? 'Delete avatar?' : 'Удалить аватар?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await deleteAvatar();
|
await deleteAvatar();
|
||||||
alert('Аватар удалён');
|
alert(isEn ? 'Avatar deleted' : 'Аватар удалён');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка удаления аватара:', error);
|
console.error('Ошибка удаления аватара:', error);
|
||||||
alert('Ошибка удаления аватара');
|
alert(isEn ? 'Error deleting avatar' : 'Ошибка удаления аватара');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -177,11 +187,11 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await updateProfile({ username, email, phoneNumber, timezone, language });
|
await updateProfile({ username, email, phoneNumber, timezone, language });
|
||||||
alert('Профиль обновлён!');
|
alert(isEn ? 'Profile updated!' : 'Профиль обновлён!');
|
||||||
onUpdate();
|
onUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка обновления профиля:', error);
|
console.error('Ошибка обновления профиля:', error);
|
||||||
alert('Ошибка обновления профиля');
|
alert(isEn ? 'Error updating profile' : 'Ошибка обновления профиля');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -190,13 +200,13 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Профиль</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Profile' : 'Профиль'}</h2>
|
||||||
<p className="text-gray-600">Обновите информацию о своём профиле</p>
|
<p className="text-gray-600">{isEn ? 'Update your profile information' : 'Обновите информацию о своём профиле'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Аватар */}
|
{/* Аватар */}
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<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="flex items-center gap-6">
|
||||||
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||||
{avatarPreview || profile?.profile?.avatarUrl ? (
|
{avatarPreview || profile?.profile?.avatarUrl ? (
|
||||||
@@ -213,7 +223,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<label className="px-4 py-2 bg-ospab-primary text-white rounded-lg cursor-pointer hover:bg-ospab-accent transition">
|
||||||
Выбрать файл
|
{isEn ? 'Choose file' : 'Выбрать файл'}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
@@ -227,7 +237,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Загрузить
|
{isEn ? 'Upload' : 'Загрузить'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{profile?.profile?.avatarUrl && (
|
{profile?.profile?.avatarUrl && (
|
||||||
@@ -236,7 +246,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Удалить
|
{isEn ? 'Delete' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
@@ -268,7 +278,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
</div>
|
</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 ? 'Phone (optional)' : 'Телефон (опционально)'}</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phoneNumber}
|
value={phoneNumber}
|
||||||
@@ -280,7 +290,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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 ? 'Timezone' : 'Часовой пояс'}</label>
|
||||||
<select
|
<select
|
||||||
value={timezone}
|
value={timezone}
|
||||||
onChange={(e) => setTimezone(e.target.value)}
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
@@ -294,7 +304,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
</div>
|
</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 ? 'Language' : 'Язык'}</label>
|
||||||
<select
|
<select
|
||||||
value={language}
|
value={language}
|
||||||
onChange={(e) => setLanguage(e.target.value)}
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
@@ -311,7 +321,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,13 +330,14 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
|
|||||||
|
|
||||||
// ============ БЕЗОПАСНОСТЬ ============
|
// ============ БЕЗОПАСНОСТЬ ============
|
||||||
const SecurityTab = () => {
|
const SecurityTab = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [view, setView] = useState<'password' | 'sessions'>('password');
|
const [view, setView] = useState<'password' | 'sessions'>('password');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Безопасность</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Security' : 'Безопасность'}</h2>
|
||||||
<p className="text-gray-600">Управление паролем и активными сеансами</p>
|
<p className="text-gray-600">{isEn ? 'Manage password and active sessions' : 'Управление паролем и активными сеансами'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sub-tabs */}
|
{/* Sub-tabs */}
|
||||||
@@ -339,7 +350,7 @@ const SecurityTab = () => {
|
|||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Смена пароля
|
{isEn ? 'Change Password' : 'Смена пароля'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('sessions')}
|
onClick={() => setView('sessions')}
|
||||||
@@ -349,7 +360,7 @@ const SecurityTab = () => {
|
|||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Активные сеансы
|
{isEn ? 'Active Sessions' : 'Активные сеансы'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,6 +371,7 @@ const SecurityTab = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PasswordChange = () => {
|
const PasswordChange = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
@@ -367,11 +379,11 @@ const PasswordChange = () => {
|
|||||||
|
|
||||||
const getPasswordStrength = (password: string) => {
|
const getPasswordStrength = (password: string) => {
|
||||||
if (password.length === 0) return { strength: 0, label: '' };
|
if (password.length === 0) return { strength: 0, label: '' };
|
||||||
if (password.length < 6) return { strength: 1, label: 'Слабый', color: 'bg-red-500' };
|
if (password.length < 6) return { strength: 1, label: isEn ? 'Weak' : 'Слабый', color: 'bg-red-500' };
|
||||||
if (password.length < 10) return { strength: 2, label: 'Средний', color: 'bg-yellow-500' };
|
if (password.length < 10) return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||||||
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password))
|
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password))
|
||||||
return { strength: 2, label: 'Средний', color: 'bg-yellow-500' };
|
return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' };
|
||||||
return { strength: 3, label: 'Сильный', color: 'bg-green-500' };
|
return { strength: 3, label: isEn ? 'Strong' : 'Сильный', color: 'bg-green-500' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const strength = getPasswordStrength(newPassword);
|
const strength = getPasswordStrength(newPassword);
|
||||||
@@ -380,20 +392,20 @@ const PasswordChange = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
alert('Пароли не совпадают');
|
alert(isEn ? 'Passwords do not match' : 'Пароли не совпадают');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await changePassword({ currentPassword, newPassword });
|
await changePassword({ currentPassword, newPassword });
|
||||||
alert('Пароль успешно изменён!');
|
alert(isEn ? 'Password changed successfully!' : 'Пароль успешно изменён!');
|
||||||
setCurrentPassword('');
|
setCurrentPassword('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка смены пароля:', error);
|
console.error('Ошибка смены пароля:', error);
|
||||||
alert('Ошибка смены пароля. Проверьте текущий пароль.');
|
alert(isEn ? 'Password change error. Check current password.' : 'Ошибка смены пароля. Проверьте текущий пароль.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -402,7 +414,7 @@ const PasswordChange = () => {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
|
<form onSubmit={handleSubmit} className="space-y-4 pt-4">
|
||||||
<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 ? 'Current password' : 'Текущий пароль'}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
@@ -413,7 +425,7 @@ const PasswordChange = () => {
|
|||||||
</div>
|
</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 ? 'New password' : 'Новый пароль'}</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
@@ -434,13 +446,13 @@ const PasswordChange = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
@@ -455,13 +467,14 @@ const PasswordChange = () => {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ActiveSessions = () => {
|
const ActiveSessions = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [loginHistory, setLoginHistory] = useState<LoginHistoryEntry[]>([]);
|
const [loginHistory, setLoginHistory] = useState<LoginHistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -494,29 +507,29 @@ const ActiveSessions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTerminate = async (id: number) => {
|
const handleTerminate = async (id: number) => {
|
||||||
if (!confirm('Вы уверены, что хотите завершить эту сессию?')) return;
|
if (!confirm(isEn ? 'Are you sure you want to terminate this session?' : 'Вы уверены, что хотите завершить эту сессию?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await terminateSession(id);
|
await terminateSession(id);
|
||||||
alert('Сеанс завершён');
|
alert(isEn ? 'Session terminated' : 'Сеанс завершён');
|
||||||
loadSessions();
|
loadSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка завершения сеанса:', error);
|
console.error('Ошибка завершения сеанса:', error);
|
||||||
alert('Не удалось завершить сессию');
|
alert(isEn ? 'Failed to terminate session' : 'Не удалось завершить сессию');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTerminateAllOthers = async () => {
|
const handleTerminateAllOthers = async () => {
|
||||||
if (!confirm('Вы уверены, что хотите завершить все остальные сессии?')) return;
|
if (!confirm(isEn ? 'Are you sure you want to terminate all other sessions?' : 'Вы уверены, что хотите завершить все остальные сессии?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Используем API для завершения всех остальных сессий
|
// Используем API для завершения всех остальных сессий
|
||||||
await apiClient.delete('/api/sessions/others/all');
|
await apiClient.delete('/api/sessions/others/all');
|
||||||
alert('Все остальные сессии завершены');
|
alert(isEn ? 'All other sessions terminated' : 'Все остальные сессии завершены');
|
||||||
loadSessions();
|
loadSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка завершения сессий:', error);
|
console.error('Ошибка завершения сессий:', error);
|
||||||
alert('Не удалось завершить сессии');
|
alert(isEn ? 'Failed to terminate sessions' : 'Не удалось завершить сессии');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -535,11 +548,11 @@ const ActiveSessions = () => {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'только что';
|
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||||||
return date.toLocaleDateString('ru-RU');
|
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
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"
|
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>
|
<span>🚫</span>
|
||||||
Завершить все остальные сессии
|
{isEn ? 'Terminate all other sessions' : 'Завершить все остальные сессии'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -567,7 +580,7 @@ const ActiveSessions = () => {
|
|||||||
{/* Сессии в виде карточек */}
|
{/* Сессии в виде карточек */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{sessions.length === 0 ? (
|
{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) => {
|
sessions.map((session) => {
|
||||||
const isCurrent = session.isCurrent || session.device?.includes('Current');
|
const isCurrent = session.isCurrent || session.device?.includes('Current');
|
||||||
@@ -583,7 +596,7 @@ const ActiveSessions = () => {
|
|||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<div className="mb-3">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -593,12 +606,12 @@ const ActiveSessions = () => {
|
|||||||
<div className="text-4xl">{getDeviceIcon(session.device || 'desktop')}</div>
|
<div className="text-4xl">{getDeviceIcon(session.device || 'desktop')}</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-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>
|
</h3>
|
||||||
<div className="space-y-1 text-sm text-gray-600">
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<span>🌐</span>
|
<span>🌐</span>
|
||||||
<span>{session.ipAddress || 'Неизвестно'}</span>
|
<span>{session.ipAddress || (isEn ? 'Unknown' : 'Неизвестно')}</span>
|
||||||
</p>
|
</p>
|
||||||
{session.location && (
|
{session.location && (
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
@@ -608,11 +621,11 @@ const ActiveSessions = () => {
|
|||||||
)}
|
)}
|
||||||
<p className="flex items-center gap-2">
|
<p className="flex items-center gap-2">
|
||||||
<span>⏱️</span>
|
<span>⏱️</span>
|
||||||
<span>Активность: {formatRelativeTime(session.lastActivity)}</span>
|
<span>{isEn ? 'Activity' : 'Активность'}: {formatRelativeTime(session.lastActivity)}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="flex items-center gap-2 text-gray-500">
|
<p className="flex items-center gap-2 text-gray-500">
|
||||||
<span>🔐</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -625,7 +638,7 @@ const ActiveSessions = () => {
|
|||||||
onClick={() => handleTerminate(session.id)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -644,8 +657,8 @@ const ActiveSessions = () => {
|
|||||||
className="w-full flex items-center justify-between text-left"
|
className="w-full flex items-center justify-between text-left"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900">История входов</h2>
|
<h2 className="text-xl font-bold text-gray-900">{isEn ? 'Login History' : 'История входов'}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">Последние 20 попыток входа в аккаунт</p>
|
<p className="text-sm text-gray-600 mt-1">{isEn ? 'Last 20 login attempts' : 'Последние 20 попыток входа в аккаунт'}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-2xl">{showHistory ? '▼' : '▶'}</span>
|
<span className="text-2xl">{showHistory ? '▼' : '▶'}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -657,16 +670,16 @@ const ActiveSessions = () => {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Статус
|
{isEn ? 'Status' : 'Статус'}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Устройство
|
{isEn ? 'Device' : 'Устройство'}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Дата и время
|
{isEn ? 'Date and Time' : 'Дата и время'}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -681,17 +694,17 @@ const ActiveSessions = () => {
|
|||||||
: 'bg-red-100 text-red-800'
|
: 'bg-red-100 text-red-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{entry.success ? '✓ Успешно' : '✗ Ошибка'}
|
{entry.success ? (isEn ? '✓ Success' : '✓ Успешно') : (isEn ? '✗ Error' : '✗ Ошибка')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{entry.ipAddress}
|
{entry.ipAddress}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -703,12 +716,12 @@ const ActiveSessions = () => {
|
|||||||
|
|
||||||
{/* Советы по безопасности */}
|
{/* Советы по безопасности */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
<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">
|
<ul className="space-y-2 text-sm text-blue-800">
|
||||||
<li>• Регулярно проверяйте список активных сессий</li>
|
<li>• {isEn ? 'Regularly check the list of active sessions' : 'Регулярно проверяйте список активных сессий'}</li>
|
||||||
<li>• Завершайте сессии на устройствах, которыми больше не пользуетесь</li>
|
<li>• {isEn ? 'Terminate sessions on devices you no longer use' : 'Завершайте сессии на устройствах, которыми больше не пользуетесь'}</li>
|
||||||
<li>• Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль</li>
|
<li>• {isEn ? 'If you see suspicious activity, immediately terminate all sessions and change password' : 'Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль'}</li>
|
||||||
<li>• Используйте надёжные пароли и двухфакторную аутентификацию</li>
|
<li>• {isEn ? 'Use strong passwords and two-factor authentication' : 'Используйте надёжные пароли и двухфакторную аутентификацию'}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -717,6 +730,7 @@ const ActiveSessions = () => {
|
|||||||
|
|
||||||
// ============ УВЕДОМЛЕНИЯ ============
|
// ============ УВЕДОМЛЕНИЯ ============
|
||||||
const NotificationsTab = () => {
|
const NotificationsTab = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -748,7 +762,7 @@ const NotificationsTab = () => {
|
|||||||
await updateNotificationSettings({ [field]: value });
|
await updateNotificationSettings({ [field]: value });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка обновления настроек:', error);
|
console.error('Ошибка обновления настроек:', error);
|
||||||
alert('Ошибка сохранения настроек');
|
alert(isEn ? 'Error saving settings' : 'Ошибка сохранения настроек');
|
||||||
loadSettings(); // Revert
|
loadSettings(); // Revert
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -760,7 +774,7 @@ const NotificationsTab = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!settings) {
|
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 = [
|
const emailSettings = [
|
||||||
@@ -779,13 +793,13 @@ const NotificationsTab = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Уведомления</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Notifications' : 'Уведомления'}</h2>
|
||||||
<p className="text-gray-600">Настройте способы получения уведомлений</p>
|
<p className="text-gray-600">{isEn ? 'Configure notification methods' : 'Настройте способы получения уведомлений'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email уведомления */}
|
{/* Email уведомления */}
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<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">
|
<div className="space-y-3">
|
||||||
{emailSettings.map((setting) => (
|
{emailSettings.map((setting) => (
|
||||||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
<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 уведомления */}
|
{/* Push уведомления */}
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<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">
|
<div className="space-y-3">
|
||||||
{pushSettings.map((setting) => (
|
{pushSettings.map((setting) => (
|
||||||
<label key={setting.key} className="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
<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 && (
|
{saving && (
|
||||||
<div className="text-sm text-gray-600 flex items-center gap-2">
|
<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>
|
<div className="w-4 h-4 border-2 border-ospab-primary border-t-transparent rounded-full animate-spin"></div>
|
||||||
Сохранение...
|
{isEn ? 'Saving...' : 'Сохранение...'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -833,6 +847,7 @@ const NotificationsTab = () => {
|
|||||||
|
|
||||||
// ============ API КЛЮЧИ ============
|
// ============ API КЛЮЧИ ============
|
||||||
const APIKeysTab = () => {
|
const APIKeysTab = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -861,20 +876,20 @@ const APIKeysTab = () => {
|
|||||||
loadKeys();
|
loadKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка создания ключа:', error);
|
console.error('Ошибка создания ключа:', error);
|
||||||
alert('Ошибка создания ключа');
|
alert(isEn ? 'Error creating key' : 'Ошибка создания ключа');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
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 {
|
try {
|
||||||
await deleteAPIKey(id);
|
await deleteAPIKey(id);
|
||||||
alert('Ключ удалён');
|
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||||||
loadKeys();
|
loadKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка удаления ключа:', error);
|
console.error('Ошибка удаления ключа:', error);
|
||||||
alert('Ошибка удаления ключа');
|
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -886,29 +901,29 @@ const APIKeysTab = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">API ключи</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'API Keys' : 'API ключи'}</h2>
|
||||||
<p className="text-gray-600">Управление ключами для интеграций</p>
|
<p className="text-gray-600">{isEn ? 'Manage integration keys' : 'Управление ключами для интеграций'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => setShowModal(true)}
|
||||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||||
>
|
>
|
||||||
Создать ключ
|
{isEn ? 'Create Key' : 'Создать ключ'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{keys.length === 0 ? (
|
{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">
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<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">{isEn ? 'Name' : 'Название'}</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">{isEn ? 'Prefix' : 'Префикс'}</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">{isEn ? 'Created' : 'Создан'}</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">{isEn ? 'Last Used' : 'Последнее использование'}</th>
|
||||||
<th className="text-right py-3 px-4 font-medium text-gray-700">Действия</th>
|
<th className="text-right py-3 px-4 font-medium text-gray-700">{isEn ? 'Actions' : 'Действия'}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -917,17 +932,17 @@ const APIKeysTab = () => {
|
|||||||
<td className="py-3 px-4">{key.name}</td>
|
<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 font-mono text-sm">{key.prefix}...</td>
|
||||||
<td className="py-3 px-4 text-sm text-gray-600">
|
<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>
|
||||||
<td className="py-3 px-4 text-sm text-gray-600">
|
<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>
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(key.id)}
|
onClick={() => handleDelete(key.id)}
|
||||||
className="text-red-600 hover:text-red-700 text-sm"
|
className="text-red-600 hover:text-red-700 text-sm"
|
||||||
>
|
>
|
||||||
Удалить
|
{isEn ? 'Delete' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -971,16 +986,16 @@ const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreat
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-4">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
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"
|
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>
|
||||||
@@ -990,14 +1005,14 @@ const CreateAPIKeyModal = ({ onClose, onCreate }: { onClose: () => void; onCreat
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Отмена
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1018,10 +1033,10 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<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">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||||
<p className="text-sm text-yellow-800 font-medium">
|
<p className="text-sm text-yellow-800 font-medium">
|
||||||
Сохраните этот ключ сейчас! Он больше не будет показан.
|
Save this key now! It will not be shown again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-100 rounded-lg p-4 mb-4 font-mono text-sm break-all">
|
<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}
|
onClick={handleCopy}
|
||||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent"
|
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent"
|
||||||
>
|
>
|
||||||
{copied ? 'Скопировано!' : 'Копировать'}
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Закрыть
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1048,6 +1063,7 @@ const ShowNewKeyModal = ({ keyData, onClose }: { keyData: { fullKey: string }; o
|
|||||||
|
|
||||||
// ============ SSH КЛЮЧИ ============
|
// ============ SSH КЛЮЧИ ============
|
||||||
const SSHKeysTab = () => {
|
const SSHKeysTab = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -1071,25 +1087,25 @@ const SSHKeysTab = () => {
|
|||||||
const handleAdd = async (name: string, publicKey: string) => {
|
const handleAdd = async (name: string, publicKey: string) => {
|
||||||
try {
|
try {
|
||||||
await addSSHKey({ name, publicKey });
|
await addSSHKey({ name, publicKey });
|
||||||
alert('SSH ключ добавлен');
|
alert(isEn ? 'SSH key added' : 'SSH ключ добавлен');
|
||||||
loadKeys();
|
loadKeys();
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка добавления ключа:', error);
|
console.error('Ошибка добавления ключа:', error);
|
||||||
alert('Ошибка добавления ключа. Проверьте формат.');
|
alert(isEn ? 'Error adding key. Check the format.' : 'Ошибка добавления ключа. Проверьте формат.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('Удалить этот SSH ключ?')) return;
|
if (!confirm(isEn ? 'Delete this SSH key?' : 'Удалить этот SSH ключ?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteSSHKey(id);
|
await deleteSSHKey(id);
|
||||||
alert('Ключ удалён');
|
alert(isEn ? 'Key deleted' : 'Ключ удалён');
|
||||||
loadKeys();
|
loadKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка удаления ключа:', error);
|
console.error('Ошибка удаления ключа:', error);
|
||||||
alert('Ошибка удаления ключа');
|
alert(isEn ? 'Error deleting key' : 'Ошибка удаления ключа');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1101,19 +1117,19 @@ const SSHKeysTab = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">SSH ключи</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'SSH Keys' : 'SSH ключи'}</h2>
|
||||||
<p className="text-gray-600">Управление SSH ключами для доступа к серверам</p>
|
<p className="text-gray-600">{isEn ? 'Manage SSH keys for server access' : 'Управление SSH ключами для доступа к серверам'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(true)}
|
onClick={() => setShowModal(true)}
|
||||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
className="px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition"
|
||||||
>
|
>
|
||||||
Добавить ключ
|
{isEn ? 'Add Key' : 'Добавить ключ'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{keys.length === 0 ? (
|
{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">
|
<div className="space-y-3">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
@@ -1124,15 +1140,15 @@ const SSHKeysTab = () => {
|
|||||||
onClick={() => handleDelete(key.id)}
|
onClick={() => handleDelete(key.id)}
|
||||||
className="text-red-600 hover:text-red-700 text-sm"
|
className="text-red-600 hover:text-red-700 text-sm"
|
||||||
>
|
>
|
||||||
Удалить
|
{isEn ? 'Delete' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mb-1">
|
<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>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Добавлен: {new Date(key.createdAt).toLocaleDateString('ru-RU')}
|
{isEn ? 'Added' : 'Добавлен'}: {new Date(key.createdAt).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}
|
||||||
{key.lastUsed && ` • Использован: ${new Date(key.lastUsed).toLocaleDateString('ru-RU')}`}
|
{key.lastUsed && ` • ${isEn ? 'Used' : 'Использован'}: ${new Date(key.lastUsed).toLocaleDateString(isEn ? 'en-US' : 'ru-RU')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1164,21 +1180,21 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-4">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
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"
|
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>
|
||||||
<div className="mb-4">
|
<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
|
<textarea
|
||||||
value={publicKey}
|
value={publicKey}
|
||||||
onChange={(e) => setPublicKey(e.target.value)}
|
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"
|
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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -1197,14 +1213,14 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
Отмена
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 px-4 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent disabled:opacity-50"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -1215,6 +1231,7 @@ const AddSSHKeyModal = ({ onClose, onAdd }: { onClose: () => void; onAdd: (name:
|
|||||||
|
|
||||||
// ============ УДАЛЕНИЕ АККАУНТА ============
|
// ============ УДАЛЕНИЕ АККАУНТА ============
|
||||||
const DeleteAccountTab = () => {
|
const DeleteAccountTab = () => {
|
||||||
|
const isEn = useSettingsLang();
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@@ -1231,44 +1248,44 @@ const DeleteAccountTab = () => {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка экспорта данных:', error);
|
console.error('Ошибка экспорта данных:', error);
|
||||||
alert('Ошибка экспорта данных');
|
alert(isEn ? 'Error exporting data' : 'Ошибка экспорта данных');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Удаление аккаунта</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">{isEn ? 'Delete Account' : 'Удаление аккаунта'}</h2>
|
||||||
<p className="text-gray-600">Экспорт данных и безвозвратное удаление аккаунта</p>
|
<p className="text-gray-600">{isEn ? 'Export data and permanently delete account' : 'Экспорт данных и безвозвратное удаление аккаунта'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Экспорт данных */}
|
{/* Экспорт данных */}
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<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">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent transition"
|
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent transition"
|
||||||
>
|
>
|
||||||
Скачать мои данные
|
{isEn ? 'Download My Data' : 'Скачать мои данные'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Удаление аккаунта */}
|
{/* Удаление аккаунта */}
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<div className="border-t border-gray-200 pt-6">
|
||||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-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="space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1276,7 +1293,7 @@ const DeleteAccountTab = () => {
|
|||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowConfirm(true)}
|
||||||
className="px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition"
|
className="px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition"
|
||||||
>
|
>
|
||||||
Удалить мой аккаунт
|
{isEn ? 'Delete My Account' : 'Удалить мой аккаунт'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useToast } from '../../hooks/useToast';
|
|||||||
import { getFiles, deleteFilesByBucket } from '../../utils/uploadDB';
|
import { getFiles, deleteFilesByBucket } from '../../utils/uploadDB';
|
||||||
import type { StorageAccessKey, StorageBucket, StorageObject } from './types';
|
import type { StorageAccessKey, StorageBucket, StorageObject } from './types';
|
||||||
import { formatBytes, formatCurrency, formatDate, getPlanTone, getStatusBadge, getUsagePercent } from './storage-utils';
|
import { formatBytes, formatCurrency, formatDate, getPlanTone, getStatusBadge, getUsagePercent } from './storage-utils';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
interface ObjectsResponse {
|
interface ObjectsResponse {
|
||||||
objects: StorageObject[];
|
objects: StorageObject[];
|
||||||
@@ -61,7 +62,7 @@ interface UploadProgress {
|
|||||||
|
|
||||||
const TEN_GIB = 10 * 1024 * 1024 * 1024;
|
const TEN_GIB = 10 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
const TAB_ITEMS = [
|
const TAB_ITEMS_RU = [
|
||||||
{
|
{
|
||||||
key: 'summary',
|
key: 'summary',
|
||||||
label: 'Сводка',
|
label: 'Сводка',
|
||||||
@@ -82,7 +83,28 @@ const TAB_ITEMS = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] 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 = {
|
type LoadObjectsOptions = {
|
||||||
reset?: boolean;
|
reset?: boolean;
|
||||||
@@ -175,7 +197,7 @@ const FileTreeView: React.FC<FileTreeViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex-1 font-medium text-gray-800">
|
<span className="flex-1 font-medium text-gray-800">
|
||||||
{node.name}
|
{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>
|
||||||
<span className="w-24 text-right text-xs text-gray-500">{formatBytes(folderSize)}</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>
|
<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">
|
<span className="text-gray-400">
|
||||||
<FiFile />
|
<FiFile />
|
||||||
</span>
|
</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-24 text-right text-xs text-gray-600">{formatBytes(node.size)}</span>
|
||||||
<span className="w-40 text-right text-xs text-gray-500">
|
<span className="w-40 text-right text-xs text-gray-500">
|
||||||
{node.lastModified ? formatDate(node.lastModified, true) : '—'}
|
{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"
|
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 />
|
<FiDownload />
|
||||||
Скачать
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -257,6 +278,9 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
const TAB_ITEMS = isEn ? TAB_ITEMS_EN : TAB_ITEMS_RU;
|
||||||
|
|
||||||
const objectPrefixRef = useRef('');
|
const objectPrefixRef = useRef('');
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -378,7 +402,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
const fetchBucket = useCallback(async (options: { silent?: boolean } = {}) => {
|
const fetchBucket = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||||
if (!bucketIdValid) {
|
if (!bucketIdValid) {
|
||||||
setBucket(null);
|
setBucket(null);
|
||||||
setBucketError('Некорректный идентификатор бакета');
|
setBucketError(isEn ? 'Invalid bucket ID' : 'Некорректный идентификатор бакета');
|
||||||
setBucketLoading(false);
|
setBucketLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -394,7 +418,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
setBucket(data.bucket);
|
setBucket(data.bucket);
|
||||||
setBucketError(null);
|
setBucketError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось загрузить бакет';
|
let message = isEn ? 'Failed to load bucket' : 'Не удалось загрузить бакет';
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
}
|
}
|
||||||
@@ -444,7 +468,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
setObjectsCursor(data.nextCursor ?? null);
|
setObjectsCursor(data.nextCursor ?? null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[StorageBucket] Не удалось получить список объектов', error);
|
console.error('[StorageBucket] Не удалось получить список объектов', error);
|
||||||
addToast('Не удалось загрузить список объектов', 'error');
|
addToast(isEn ? 'Failed to load objects list' : 'Не удалось загрузить список объектов', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setObjectsLoading(false);
|
setObjectsLoading(false);
|
||||||
setObjectsLoadingMore(false);
|
setObjectsLoadingMore(false);
|
||||||
@@ -462,7 +486,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
setAccessKeys(data.keys);
|
setAccessKeys(data.keys);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[StorageBucket] Не удалось получить ключи доступа', error);
|
console.error('[StorageBucket] Не удалось получить ключи доступа', error);
|
||||||
addToast('Не удалось загрузить ключи доступа', 'error');
|
addToast(isEn ? 'Failed to load access keys' : 'Не удалось загрузить ключи доступа', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setAccessKeysLoading(false);
|
setAccessKeysLoading(false);
|
||||||
}
|
}
|
||||||
@@ -481,7 +505,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
dispatchBucketsRefresh();
|
dispatchBucketsRefresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось обновить настройки бакета';
|
let message = isEn ? 'Failed to update bucket settings' : 'Не удалось обновить настройки бакета';
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
}
|
}
|
||||||
@@ -496,7 +520,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = !bucket.public;
|
const next = !bucket.public;
|
||||||
updateBucketSettings({ public: next }, next ? 'Публичный доступ включён' : 'Публичный доступ отключён');
|
updateBucketSettings({ public: next }, next ? (isEn ? 'Public access enabled' : 'Публичный доступ включён') : (isEn ? 'Public access disabled' : 'Публичный доступ отключён'));
|
||||||
}, [bucket, updateBucketSettings]);
|
}, [bucket, updateBucketSettings]);
|
||||||
|
|
||||||
const toggleVersioning = useCallback(() => {
|
const toggleVersioning = useCallback(() => {
|
||||||
@@ -504,7 +528,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = !bucket.versioning;
|
const next = !bucket.versioning;
|
||||||
updateBucketSettings({ versioning: next }, next ? 'Версионирование включено' : 'Версионирование отключено');
|
updateBucketSettings({ versioning: next }, next ? (isEn ? 'Versioning enabled' : 'Версионирование включено') : (isEn ? 'Versioning disabled' : 'Версионирование отключено'));
|
||||||
}, [bucket, updateBucketSettings]);
|
}, [bucket, updateBucketSettings]);
|
||||||
|
|
||||||
const toggleAutoRenew = useCallback(() => {
|
const toggleAutoRenew = useCallback(() => {
|
||||||
@@ -512,7 +536,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = !bucket.autoRenew;
|
const next = !bucket.autoRenew;
|
||||||
updateBucketSettings({ autoRenew: next }, next ? 'Автопродление включено' : 'Автопродление отключено');
|
updateBucketSettings({ autoRenew: next }, next ? (isEn ? 'Auto-renewal enabled' : 'Автопродление включено') : (isEn ? 'Auto-renewal disabled' : 'Автопродление отключено'));
|
||||||
}, [bucket, updateBucketSettings]);
|
}, [bucket, updateBucketSettings]);
|
||||||
|
|
||||||
const handleRefreshBucket = useCallback(() => {
|
const handleRefreshBucket = useCallback(() => {
|
||||||
@@ -564,7 +588,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleDownloadObject = useCallback(async (object: StorageObject) => {
|
const handleDownloadObject = useCallback(async (object: StorageObject) => {
|
||||||
if (object.size >= TEN_GIB) {
|
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) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -586,7 +610,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось скачать объект';
|
let message = isEn ? 'Failed to download object' : 'Не удалось скачать объект';
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
}
|
}
|
||||||
@@ -613,7 +637,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
fetchBucket({ silent: true });
|
fetchBucket({ silent: true });
|
||||||
dispatchBucketsRefresh();
|
dispatchBucketsRefresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось удалить объекты';
|
let message = isEn ? 'Failed to delete objects' : 'Не удалось удалить объекты';
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
}
|
}
|
||||||
@@ -661,7 +685,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
// Проверяем отмену
|
// Проверяем отмену
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
throw new Error('Загрузка отменена');
|
throw new Error(isEn ? 'Upload cancelled' : 'Загрузка отменена');
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = files[i];
|
const file = files[i];
|
||||||
@@ -749,7 +773,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
fetchBucket({ silent: true });
|
fetchBucket({ silent: true });
|
||||||
dispatchBucketsRefresh();
|
dispatchBucketsRefresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось загрузить файлы';
|
let message = isEn ? 'Failed to upload files' : 'Не удалось загрузить файлы';
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
message = error.message;
|
message = error.message;
|
||||||
}
|
}
|
||||||
@@ -774,8 +798,8 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
setUploading(false);
|
setUploading(false);
|
||||||
setUploadProgress({});
|
setUploadProgress({});
|
||||||
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: 0 });
|
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: 0 });
|
||||||
addToast('Загрузка отменена', 'info');
|
addToast(isEn ? 'Upload cancelled' : 'Загрузка отменена', 'info');
|
||||||
}, [addToast]);
|
}, [addToast, isEn]);
|
||||||
|
|
||||||
const handleClickSelectFiles = useCallback(() => {
|
const handleClickSelectFiles = useCallback(() => {
|
||||||
if (fileDialogOpenRef.current || uploading) {
|
if (fileDialogOpenRef.current || uploading) {
|
||||||
@@ -809,7 +833,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleUriUpload = useCallback(async () => {
|
const handleUriUpload = useCallback(async () => {
|
||||||
if (!uriUploadUrl.trim()) {
|
if (!uriUploadUrl.trim()) {
|
||||||
addToast('Введите URL', 'error');
|
addToast(isEn ? 'Enter URL' : 'Введите URL', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,15 +889,15 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
addToast(`Файл "${fileName}" загружен`, 'success');
|
addToast(`Файл "${fileName}" загружен`, 'success');
|
||||||
} else {
|
} else {
|
||||||
console.error('[URI Upload] Ответ не содержит blob:', response.data);
|
console.error('[URI Upload] Ответ не содержит blob:', response.data);
|
||||||
addToast('Сервер не вернул данные файла', 'error');
|
addToast(isEn ? 'Server returned no file data' : 'Сервер не вернул данные файла', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[URI Upload] Ошибка:', error);
|
console.error('[URI Upload] Error:', error);
|
||||||
let message = 'Не удалось загрузить по URI';
|
let message = isEn ? 'Failed to upload by URI' : 'Не удалось загрузить по URI';
|
||||||
if (error instanceof Error && error.message === 'canceled') {
|
if (error instanceof Error && error.message === 'canceled') {
|
||||||
message = 'Загрузка отменена';
|
message = isEn ? 'Upload cancelled' : 'Загрузка отменена';
|
||||||
} else if (isAxiosError(error) && error.response?.data?.error) {
|
} 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;
|
message = error.response.data.error;
|
||||||
} else if (error instanceof Error) {
|
} else if (error instanceof Error) {
|
||||||
message = error.message;
|
message = error.message;
|
||||||
@@ -883,7 +907,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
setUriUploadLoading(false);
|
setUriUploadLoading(false);
|
||||||
uriUploadAbortControllerRef.current = null;
|
uriUploadAbortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
}, [uriUploadUrl, performUpload, addToast, bucketNumber]);
|
}, [uriUploadUrl, performUpload, addToast, bucketNumber, isEn]);
|
||||||
|
|
||||||
const handleCancelUriUpload = useCallback(() => {
|
const handleCancelUriUpload = useCallback(() => {
|
||||||
if (uriUploadAbortControllerRef.current) {
|
if (uriUploadAbortControllerRef.current) {
|
||||||
@@ -891,8 +915,8 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
uriUploadAbortControllerRef.current = null;
|
uriUploadAbortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
setUriUploadLoading(false);
|
setUriUploadLoading(false);
|
||||||
addToast('Загрузка отменена', 'info');
|
addToast(isEn ? 'Upload cancelled' : 'Загрузка отменена', 'info');
|
||||||
}, [addToast]);
|
}, [addToast, isEn]);
|
||||||
|
|
||||||
const handleUploadInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUploadInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { files } = event.target;
|
const { files } = event.target;
|
||||||
@@ -914,7 +938,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
return file.size > 0 || file.type !== '';
|
return file.size > 0 || file.type !== '';
|
||||||
});
|
});
|
||||||
if (fileArray.length === 0) {
|
if (fileArray.length === 0) {
|
||||||
addToast('Папка пуста или не содержит файлов', 'warning');
|
addToast(isEn ? 'Folder is empty or contains no files' : 'Папка пуста или не содержит файлов', 'warning');
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -961,7 +985,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
addToast(`${label} скопирован`, 'success');
|
addToast(`${label} скопирован`, 'success');
|
||||||
} catch {
|
} catch {
|
||||||
addToast('Не удалось скопировать в буфер обмена', 'error');
|
addToast(isEn ? 'Failed to copy to clipboard' : 'Не удалось скопировать в буфер обмена', 'error');
|
||||||
}
|
}
|
||||||
}, [addToast]);
|
}, [addToast]);
|
||||||
|
|
||||||
@@ -976,10 +1000,10 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setNewKeyLabel('');
|
setNewKeyLabel('');
|
||||||
setLastCreatedKey(data.key);
|
setLastCreatedKey(data.key);
|
||||||
addToast('Создан новый ключ доступа', 'success');
|
addToast(isEn ? 'New access key created' : 'Создан новый ключ доступа', 'success');
|
||||||
fetchAccessKeys();
|
fetchAccessKeys();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось создать ключ';
|
let message = isEn ? 'Failed to create key' : 'Не удалось создать ключ';
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
}
|
}
|
||||||
@@ -990,7 +1014,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
}, [addToast, bucketIdValid, bucketNumber, creatingKey, fetchAccessKeys, newKeyLabel]);
|
}, [addToast, bucketIdValid, bucketNumber, creatingKey, fetchAccessKeys, newKeyLabel]);
|
||||||
|
|
||||||
const handleRevokeAccessKey = useCallback(async (keyId: number) => {
|
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) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -998,9 +1022,9 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await apiClient.delete(`/api/storage/buckets/${bucketNumber}/access-keys/${keyId}`);
|
await apiClient.delete(`/api/storage/buckets/${bucketNumber}/access-keys/${keyId}`);
|
||||||
setAccessKeys((prev) => prev.filter((key) => key.id !== keyId));
|
setAccessKeys((prev) => prev.filter((key) => key.id !== keyId));
|
||||||
addToast('Ключ удалён', 'success');
|
addToast(isEn ? 'Key deleted' : 'Ключ удалён', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = 'Не удалось удалить ключ';
|
let message = isEn ? 'Failed to delete key' : 'Не удалось удалить ключ';
|
||||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||||
message = error.response.data.error;
|
message = error.response.data.error;
|
||||||
}
|
}
|
||||||
@@ -1064,7 +1088,7 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bucketIdValid) {
|
if (!bucketIdValid) {
|
||||||
setBucket(null);
|
setBucket(null);
|
||||||
setBucketError('Некорректный идентификатор бакета');
|
setBucketError(isEn ? 'Invalid bucket ID' : 'Некорректный идентификатор бакета');
|
||||||
setBucketLoading(false);
|
setBucketLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1103,31 +1127,31 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
<section className="bg-white rounded-xl shadow-md p-6 space-y-6">
|
<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 className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||||
<div>
|
<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="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>
|
<p className="text-xs text-gray-500">{bucket.regionDetails?.endpoint ?? bucket.regionDetails?.code ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<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="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>
|
<p className="text-xs text-gray-500">{bucket.storageClassDetails?.description ?? bucket.storageClassDetails?.code ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<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="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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-gray-500 mb-1">Биллинг</p>
|
<p className="text-xs uppercase text-gray-500 mb-1">{isEn ? 'Billing' : 'Биллинг'}</p>
|
||||||
<p className="font-semibold text-gray-800">Следующее списание: {formatDate(bucket.nextBillingDate)}</p>
|
<p className="font-semibold text-gray-800">{isEn ? 'Next charge' : 'Следующее списание'}: {formatDate(bucket.nextBillingDate)}</p>
|
||||||
<p className="text-xs text-gray-500">Последнее списание: {formatDate(bucket.lastBilledAt)}</p>
|
<p className="text-xs text-gray-500">{isEn ? 'Last charge' : 'Последнее списание'}: {formatDate(bucket.lastBilledAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||||
<span>Использовано: {formatBytes(bucket.usedBytes)}</span>
|
<span>{isEn ? 'Used' : 'Использовано'}: {formatBytes(bucket.usedBytes)}</span>
|
||||||
<span>Квота: {bucket.quotaGb} GB</span>
|
<span>{isEn ? 'Quota' : 'Квота'}: {bucket.quotaGb} GB</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
@@ -1138,19 +1162,19 @@ const StorageBucketPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-2">
|
<div className="flex flex-wrap gap-4 text-xs text-gray-500 mt-2">
|
||||||
<span>{bucketUsagePercent.toFixed(1)}% квоты использовано</span>
|
<span>{bucketUsagePercent.toFixed(1)}% {isEn ? 'quota used' : 'квоты использовано'}</span>
|
||||||
<span>Объектов: {bucket.objectCount}</span>
|
<span>{isEn ? 'Objects' : 'Объектов'}: {bucket.objectCount}</span>
|
||||||
<span>Синхронизация: {formatDate(bucket.usageSyncedAt, true)}</span>
|
<span>{isEn ? 'Sync' : 'Синхронизация'}: {formatDate(bucket.usageSyncedAt, true)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
|
<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">
|
<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>
|
<p className="font-semibold text-gray-800">{formatDate(bucket.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<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>
|
<p className="font-semibold text-gray-800">{formatDate(bucket.updatedAt, true)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<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">
|
<div className="mt-6 space-y-4 bg-ospab-primary/5 p-4 rounded-lg border border-ospab-primary/20">
|
||||||
{uploadStats.currentFile && (
|
{uploadStats.currentFile && (
|
||||||
<div className="text-sm text-gray-700 font-semibold">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{uploadProgress.__total__ && (
|
{uploadProgress.__total__ && (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import { useToast } from '../../hooks/useToast';
|
import { useToast } from '../../hooks/useToast';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
import type { StorageBucket } from './types';
|
import type { StorageBucket } from './types';
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -54,6 +55,8 @@ const StoragePage: React.FC = () => {
|
|||||||
|
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
const [bucketActions, setBucketActions] = useState<Record<number, boolean>>({});
|
const [bucketActions, setBucketActions] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
const fetchBuckets = useCallback(async (notify = false) => {
|
const fetchBuckets = useCallback(async (notify = false) => {
|
||||||
@@ -63,16 +66,16 @@ const StoragePage: React.FC = () => {
|
|||||||
setBuckets(response.data?.buckets ?? []);
|
setBuckets(response.data?.buckets ?? []);
|
||||||
setError(null);
|
setError(null);
|
||||||
if (notify) {
|
if (notify) {
|
||||||
addToast('Список бакетов обновлён', 'success');
|
addToast(isEn ? 'Bucket list updated' : 'Список бакетов обновлён', 'success');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Не удалось загрузить бакеты', err);
|
console.error('[Storage] Не удалось загрузить бакеты', err);
|
||||||
setError('Не удалось загрузить список хранилищ');
|
setError(isEn ? 'Failed to load storage list' : 'Не удалось загрузить список хранилищ');
|
||||||
addToast('Не удалось получить список бакетов', 'error');
|
addToast(isEn ? 'Failed to get bucket list' : 'Не удалось получить список бакетов', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingBuckets(false);
|
setLoadingBuckets(false);
|
||||||
}
|
}
|
||||||
}, [addToast]);
|
}, [addToast, isEn]);
|
||||||
|
|
||||||
const fetchStatus = useCallback(async (notify = false) => {
|
const fetchStatus = useCallback(async (notify = false) => {
|
||||||
try {
|
try {
|
||||||
@@ -80,17 +83,17 @@ const StoragePage: React.FC = () => {
|
|||||||
const response = await apiClient.get<StorageStatus>('/api/storage/status');
|
const response = await apiClient.get<StorageStatus>('/api/storage/status');
|
||||||
setStatus(response.data);
|
setStatus(response.data);
|
||||||
if (notify && response.data.minio.connected) {
|
if (notify && response.data.minio.connected) {
|
||||||
addToast('Подключение к MinIO активно', 'success');
|
addToast(isEn ? 'MinIO connection active' : 'Подключение к MinIO активно', 'success');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Storage] Не удалось получить статус', err);
|
console.error('[Storage] Не удалось получить статус', err);
|
||||||
if (notify) {
|
if (notify) {
|
||||||
addToast('Не удалось обновить статус MinIO', 'warning');
|
addToast(isEn ? 'Failed to update MinIO status' : 'Не удалось обновить статус MinIO', 'warning');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingStatus(false);
|
setLoadingStatus(false);
|
||||||
}
|
}
|
||||||
}, [addToast]);
|
}, [addToast, isEn]);
|
||||||
|
|
||||||
const setBucketBusy = useCallback((id: number, busy: boolean) => {
|
const setBucketBusy = useCallback((id: number, busy: boolean) => {
|
||||||
setBucketActions((prev) => {
|
setBucketActions((prev) => {
|
||||||
@@ -222,9 +225,9 @@ const StoragePage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
|
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
|
||||||
<FiDatabase className="text-ospab-primary" />
|
<FiDatabase className="text-ospab-primary" />
|
||||||
S3 Хранилище
|
{isEn ? 'S3 Storage' : 'S3 Хранилище'}
|
||||||
</h1>
|
</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>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<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"
|
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' : ''} />
|
<FiRefreshCw className={loadingBuckets ? 'animate-spin' : ''} />
|
||||||
Обновить список
|
{isEn ? 'Refresh list' : 'Обновить список'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/tariffs')}
|
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"
|
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 />
|
<FiPlus />
|
||||||
Создать бакет
|
{isEn ? 'Create bucket' : 'Создать бакет'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,9 +264,11 @@ const StoragePage: React.FC = () => {
|
|||||||
<FiAlertTriangle className="text-red-500 text-2xl" />
|
<FiAlertTriangle className="text-red-500 text-2xl" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<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">
|
<p className="text-sm text-gray-500">
|
||||||
{minioStatus?.connected ? 'Подключение установлено' : 'Нет связи с хранилищем. Попробуйте обновить статус.'}
|
{minioStatus?.connected
|
||||||
|
? (isEn ? 'Connection established' : 'Подключение установлено')
|
||||||
|
: (isEn ? 'No connection to storage. Try refreshing status.' : 'Нет связи с хранилищем. Попробуйте обновить статус.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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' : ''} />
|
<FiRefreshCw className={loadingStatus ? 'animate-spin' : ''} />
|
||||||
Проверить статус
|
{isEn ? 'Check status' : 'Проверить статус'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingStatus ? (
|
{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 ? (
|
) : status ? (
|
||||||
<div className="px-6 py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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">
|
<div className="space-y-3 text-sm text-gray-600">
|
||||||
@@ -287,11 +292,11 @@ const StoragePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FiInfo className="text-ospab-primary" />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FiInfo className="text-ospab-primary" />
|
<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>
|
</div>
|
||||||
{minioStatus?.error && !minioStatus.connected && (
|
{minioStatus?.error && !minioStatus.connected && (
|
||||||
<div className="flex items-center gap-2 text-red-600">
|
<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 className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-gray-500 mb-1">Регион по умолчанию</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 ?? 'Не выбран'}</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>
|
<p className="text-xs text-gray-500">{status.defaults.region?.endpoint ?? status.defaults.region?.code ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения по умолчанию</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 ?? 'Не выбран'}</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>
|
<p className="text-xs text-gray-500">{status.defaults.storageClass?.description ?? status.defaults.storageClass?.code ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
<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>{isEn ? 'Active plans:' : 'Активных тарифов:'} <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>{isEn ? 'Regions:' : 'Регионов:'} <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 ? 'Storage classes:' : 'Классов хранения:'} <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-6 py-8 text-sm text-gray-500 flex items-center gap-2">
|
<div className="px-6 py-8 text-sm text-gray-500 flex items-center gap-2">
|
||||||
<FiInfo />
|
<FiInfo />
|
||||||
Нет данных о статусе хранилища. Попробуйте обновить.
|
{isEn ? 'No storage status data. Try refreshing.' : 'Нет данных о статусе хранилища. Попробуйте обновить.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="bg-white rounded-xl shadow-md p-5">
|
<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-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>
|
||||||
<div className="bg-white rounded-xl shadow-md p-5">
|
<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-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>
|
||||||
<div className="bg-white rounded-xl shadow-md p-5">
|
<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-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,14 +358,14 @@ const StoragePage: React.FC = () => {
|
|||||||
) : buckets.length === 0 ? (
|
) : buckets.length === 0 ? (
|
||||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||||
<FiDatabase className="text-6xl text-gray-300 mx-auto mb-4" />
|
<FiDatabase className="text-6xl text-gray-300 mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Нет активных хранилищ</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-2">{isEn ? 'No active storage' : 'Нет активных хранилищ'}</h3>
|
||||||
<p className="text-gray-600 mb-6">Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.</p>
|
<p className="text-gray-600 mb-6">{isEn ? 'Create your first S3 bucket for storing files, backups and media content.' : 'Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.'}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/tariffs')}
|
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"
|
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 />
|
<FiPlus />
|
||||||
Выбрать тариф
|
{isEn ? 'Choose plan' : 'Выбрать тариф'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -384,7 +389,7 @@ const StoragePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<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}`}>
|
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${planTone}`}>
|
||||||
{planName}
|
{planName}
|
||||||
</span>
|
</span>
|
||||||
@@ -392,7 +397,7 @@ const StoragePage: React.FC = () => {
|
|||||||
{statusBadge.label}
|
{statusBadge.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
import type { UserData, Ticket } from './types';
|
import type { UserData, Ticket } from './types';
|
||||||
|
|
||||||
@@ -7,6 +8,9 @@ interface SummaryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Summary = ({ userData }: SummaryProps) => {
|
const Summary = ({ userData }: SummaryProps) => {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
// Фильтрация открытых тикетов
|
// Фильтрация открытых тикетов
|
||||||
const openTickets = Array.isArray(userData.tickets)
|
const openTickets = Array.isArray(userData.tickets)
|
||||||
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
|
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
|
||||||
@@ -14,21 +18,30 @@ const Summary = ({ userData }: SummaryProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 lg:p-8 bg-white rounded-2xl lg:rounded-3xl shadow-xl">
|
<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="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">
|
<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>
|
<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>
|
||||||
<div className="bg-gray-100 p-4 lg:p-6 rounded-xl lg:rounded-2xl flex flex-col items-start">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base lg:text-lg text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { UserData } from './types';
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
|
||||||
interface Ticket {
|
interface Ticket {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -35,6 +36,8 @@ type TicketsPageProps = {
|
|||||||
|
|
||||||
const TicketsPage: React.FC<TicketsPageProps> = () => {
|
const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@@ -69,11 +72,11 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const badges: Record<string, { color: string; text: string }> = {
|
const badges: Record<string, { color: string; text: string }> = {
|
||||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт' },
|
open: { color: 'bg-green-100 text-green-800', text: isEn ? 'Open' : 'Открыт' },
|
||||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе' },
|
in_progress: { color: 'bg-blue-100 text-blue-800', text: isEn ? 'In Progress' : 'В работе' },
|
||||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа' },
|
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: isEn ? 'Awaiting Reply' : 'Ожидает ответа' },
|
||||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён' },
|
resolved: { color: 'bg-purple-100 text-purple-800', text: isEn ? 'Resolved' : 'Решён' },
|
||||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт' }
|
closed: { color: 'bg-gray-100 text-gray-800', text: isEn ? 'Closed' : 'Закрыт' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const badge = badges[status] || badges.open;
|
const badge = badges[status] || badges.open;
|
||||||
@@ -87,10 +90,10 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
|
|
||||||
const getPriorityBadge = (priority: string) => {
|
const getPriorityBadge = (priority: string) => {
|
||||||
const badges: Record<string, { color: string; text: string }> = {
|
const badges: Record<string, { color: string; text: string }> = {
|
||||||
urgent: { color: 'bg-red-100 text-red-800 border-red-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: 'Высокий' },
|
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: 'Обычный' },
|
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: 'Низкий' }
|
low: { color: 'bg-green-100 text-green-800 border-green-300', text: isEn ? 'Low' : 'Низкий' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const badge = badges[priority] || badges.normal;
|
const badge = badges[priority] || badges.normal;
|
||||||
@@ -121,11 +124,11 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'только что';
|
if (diffMins < 1) return isEn ? 'just now' : 'только что';
|
||||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`;
|
||||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`;
|
||||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`;
|
||||||
return date.toLocaleDateString('ru-RU');
|
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -133,7 +136,7 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -145,15 +148,15 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Тикеты поддержки</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||||
<p className="text-gray-600">Управляйте вашими обращениями в службу поддержки</p>
|
<p className="text-gray-600">{isEn ? 'Manage your support requests' : 'Управляйте вашими обращениями в службу поддержки'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/dashboard/tickets/new')}
|
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"
|
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>
|
<span>➕</span>
|
||||||
Создать тикет
|
{isEn ? 'Create Ticket' : 'Создать тикет'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,50 +165,50 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{/* Status Filter */}
|
{/* Status Filter */}
|
||||||
<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 ? 'Status' : 'Статус'}</label>
|
||||||
<select
|
<select
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
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"
|
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="all">{isEn ? 'All Statuses' : 'Все статусы'}</option>
|
||||||
<option value="open">Открыт</option>
|
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||||
<option value="in_progress">В работе</option>
|
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||||
<option value="awaiting_reply">Ожидает ответа</option>
|
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||||
<option value="resolved">Решён</option>
|
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||||
<option value="closed">Закрыт</option>
|
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category Filter */}
|
||||||
<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 ? 'Category' : 'Категория'}</label>
|
||||||
<select
|
<select
|
||||||
value={filters.category}
|
value={filters.category}
|
||||||
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
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"
|
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="all">{isEn ? 'All Categories' : 'Все категории'}</option>
|
||||||
<option value="general">Общие вопросы</option>
|
<option value="general">{isEn ? 'General Questions' : 'Общие вопросы'}</option>
|
||||||
<option value="technical">Технические</option>
|
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||||
<option value="billing">Биллинг</option>
|
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||||
<option value="other">Другое</option>
|
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority Filter */}
|
{/* Priority Filter */}
|
||||||
<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 ? 'Priority' : 'Приоритет'}</label>
|
||||||
<select
|
<select
|
||||||
value={filters.priority}
|
value={filters.priority}
|
||||||
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
|
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"
|
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="all">{isEn ? 'All Priorities' : 'Все приоритеты'}</option>
|
||||||
<option value="urgent">Срочно</option>
|
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||||
<option value="high">Высокий</option>
|
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||||
<option value="normal">Обычный</option>
|
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||||
<option value="low">Низкий</option>
|
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,13 +217,13 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
|||||||
{/* Tickets Grid */}
|
{/* Tickets Grid */}
|
||||||
{tickets.length === 0 ? (
|
{tickets.length === 0 ? (
|
||||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">{isEn ? 'No Tickets' : 'Нет тикетов'}</h3>
|
||||||
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
|
<p className="text-gray-600 mb-6">{isEn ? 'You have no open support tickets yet' : 'У вас пока нет открытых тикетов поддержки'}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/dashboard/tickets/new')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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 justify-between pt-4 border-t border-gray-200">
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||||
<span>{ticket.responses?.length || 0} ответов</span>
|
<span>{ticket.responses?.length || 0} {isEn ? 'replies' : 'ответов'}</span>
|
||||||
{ticket.closedAt && (
|
{ticket.closedAt && (
|
||||||
<span>Закрыт</span>
|
<span>{isEn ? 'Closed' : 'Закрыт'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
||||||
Открыть →
|
{isEn ? 'Open →' : 'Открыть →'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
|||||||
import apiClient from '../../../utils/apiClient';
|
import apiClient from '../../../utils/apiClient';
|
||||||
import AuthContext from '../../../context/authcontext';
|
import AuthContext from '../../../context/authcontext';
|
||||||
import { useToast } from '../../../hooks/useToast';
|
import { useToast } from '../../../hooks/useToast';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
|
||||||
interface TicketAuthor {
|
interface TicketAuthor {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -48,7 +49,7 @@ interface TicketDetail {
|
|||||||
responses: TicketResponse[];
|
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' },
|
open: { text: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||||
in_progress: { text: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
in_progress: { text: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||||
awaiting_reply: { text: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-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' },
|
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' },
|
urgent: { text: 'Срочно', badge: 'bg-red-100 text-red-800' },
|
||||||
high: { text: 'Высокий', badge: 'bg-orange-100 text-orange-800' },
|
high: { text: 'Высокий', badge: 'bg-orange-100 text-orange-800' },
|
||||||
normal: { text: 'Обычный', badge: 'bg-gray-100 text-gray-800' },
|
normal: { text: 'Обычный', badge: 'bg-gray-100 text-gray-800' },
|
||||||
low: { text: 'Низкий', badge: 'bg-green-100 text-green-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 TicketDetailPage = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { userData } = useContext(AuthContext);
|
const { userData } = useContext(AuthContext);
|
||||||
const { addToast } = useToast();
|
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 isOperator = Boolean(userData?.user?.operator);
|
||||||
const currentUserId = userData?.user?.id ?? null;
|
const currentUserId = userData?.user?.id ?? null;
|
||||||
@@ -85,7 +106,7 @@ const TicketDetailPage = () => {
|
|||||||
|
|
||||||
const fetchTicket = async () => {
|
const fetchTicket = async () => {
|
||||||
if (!ticketId) {
|
if (!ticketId) {
|
||||||
setError('Некорректный идентификатор тикета');
|
setError(isEn ? 'Invalid ticket ID' : 'Некорректный идентификатор тикета');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,7 +120,7 @@ const TicketDetailPage = () => {
|
|||||||
setTicket(payload);
|
setTicket(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка загрузки тикета:', err);
|
console.error('Ошибка загрузки тикета:', err);
|
||||||
setError('Не удалось загрузить тикет');
|
setError(isEn ? 'Failed to load ticket' : 'Не удалось загрузить тикет');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -138,11 +159,11 @@ const TicketDetailPage = () => {
|
|||||||
|
|
||||||
setReply('');
|
setReply('');
|
||||||
setIsInternalNote(false);
|
setIsInternalNote(false);
|
||||||
addToast('Ответ отправлен', 'success');
|
addToast(isEn ? 'Reply sent' : 'Ответ отправлен', 'success');
|
||||||
fetchTicket();
|
fetchTicket();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка отправки ответа:', err);
|
console.error('Ошибка отправки ответа:', err);
|
||||||
addToast('Не удалось отправить ответ', 'error');
|
addToast(isEn ? 'Failed to send reply' : 'Не удалось отправить ответ', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -151,17 +172,17 @@ const TicketDetailPage = () => {
|
|||||||
const handleCloseTicket = async () => {
|
const handleCloseTicket = async () => {
|
||||||
if (!ticketId) return;
|
if (!ticketId) return;
|
||||||
|
|
||||||
const confirmation = window.confirm('Вы уверены, что хотите закрыть тикет?');
|
const confirmation = window.confirm(isEn ? 'Are you sure you want to close this ticket?' : 'Вы уверены, что хотите закрыть тикет?');
|
||||||
if (!confirmation) return;
|
if (!confirmation) return;
|
||||||
|
|
||||||
setStatusProcessing(true);
|
setStatusProcessing(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ticket/close', { ticketId });
|
await apiClient.post('/api/ticket/close', { ticketId });
|
||||||
addToast('Тикет закрыт', 'success');
|
addToast(isEn ? 'Ticket closed' : 'Тикет закрыт', 'success');
|
||||||
fetchTicket();
|
fetchTicket();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка закрытия тикета:', err);
|
console.error('Ошибка закрытия тикета:', err);
|
||||||
addToast('Не удалось закрыть тикет', 'error');
|
addToast(isEn ? 'Failed to close ticket' : 'Не удалось закрыть тикет', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setStatusProcessing(false);
|
setStatusProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -173,11 +194,11 @@ const TicketDetailPage = () => {
|
|||||||
setStatusProcessing(true);
|
setStatusProcessing(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ticket/status', { ticketId, status });
|
await apiClient.post('/api/ticket/status', { ticketId, status });
|
||||||
addToast('Статус обновлён', 'success');
|
addToast(isEn ? 'Status updated' : 'Статус обновлён', 'success');
|
||||||
fetchTicket();
|
fetchTicket();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка изменения статуса:', err);
|
console.error('Ошибка изменения статуса:', err);
|
||||||
addToast('Не удалось изменить статус', 'error');
|
addToast(isEn ? 'Failed to update status' : 'Не удалось изменить статус', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setStatusProcessing(false);
|
setStatusProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -189,11 +210,11 @@ const TicketDetailPage = () => {
|
|||||||
setAssigning(true);
|
setAssigning(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ticket/assign', { ticketId, operatorId: currentUserId });
|
await apiClient.post('/api/ticket/assign', { ticketId, operatorId: currentUserId });
|
||||||
addToast('Тикет назначен на вас', 'success');
|
addToast(isEn ? 'Ticket assigned to you' : 'Тикет назначен на вас', 'success');
|
||||||
fetchTicket();
|
fetchTicket();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка назначения тикета:', err);
|
console.error('Ошибка назначения тикета:', err);
|
||||||
addToast('Не удалось назначить тикет', 'error');
|
addToast(isEn ? 'Failed to assign ticket' : 'Не удалось назначить тикет', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setAssigning(false);
|
setAssigning(false);
|
||||||
}
|
}
|
||||||
@@ -217,7 +238,7 @@ const TicketDetailPage = () => {
|
|||||||
<div className="flex min-h-screen items-center justify-center">
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<div className="text-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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -227,10 +248,10 @@ const TicketDetailPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4">
|
<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">
|
<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>
|
<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">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,10 +262,10 @@ const TicketDetailPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4">
|
<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">
|
<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>
|
<h2 className="text-lg font-semibold text-gray-900">{isEn ? 'Ticket not found' : 'Тикет не найден'}</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">Возможно, он был удалён или у вас нет доступа.</p>
|
<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">
|
<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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +282,7 @@ const TicketDetailPage = () => {
|
|||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition hover:text-gray-800"
|
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition hover:text-gray-800"
|
||||||
>
|
>
|
||||||
← Назад
|
← {isEn ? 'Back' : 'Назад'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
<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}`}>
|
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${priorityMeta.badge}`}>
|
||||||
{priorityMeta.text}
|
{priorityMeta.text}
|
||||||
</span>
|
</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 && (
|
{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">
|
<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}
|
{ticket.assignedOperator.username}
|
||||||
@@ -282,9 +303,9 @@ const TicketDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 text-sm text-gray-600">
|
<div className="flex flex-col gap-2 text-sm text-gray-600">
|
||||||
<span>Создан: {formatDateTime(ticket.createdAt)}</span>
|
<span>{isEn ? 'Created' : 'Создан'}: {formatDateTime(ticket.createdAt)}</span>
|
||||||
<span>Обновлён: {formatDateTime(ticket.updatedAt)}</span>
|
<span>{isEn ? 'Updated' : 'Обновлён'}: {formatDateTime(ticket.updatedAt)}</span>
|
||||||
{ticket.closedAt && <span>Закрыт: {formatDateTime(ticket.closedAt)}</span>}
|
{ticket.closedAt && <span>{isEn ? 'Closed' : 'Закрыт'}: {formatDateTime(ticket.closedAt)}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,12 +315,14 @@ const TicketDetailPage = () => {
|
|||||||
|
|
||||||
{ticket.attachments.length > 0 && (
|
{ticket.attachments.length > 0 && (
|
||||||
<div className="mt-4">
|
<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">
|
<ul className="mt-2 flex flex-wrap gap-3 text-sm text-blue-600">
|
||||||
{ticket.attachments.map((attachment) => (
|
{ticket.attachments.map((attachment) => (
|
||||||
<li key={attachment.id}>
|
<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">
|
<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">
|
||||||
📎 {attachment.filename}
|
<span className="truncate max-w-[200px] sm:max-w-[300px] md:max-w-none" title={attachment.filename}>
|
||||||
|
📎 {attachment.filename}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -315,7 +338,7 @@ const TicketDetailPage = () => {
|
|||||||
disabled={assigning}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -328,7 +351,7 @@ const TicketDetailPage = () => {
|
|||||||
disabled={statusProcessing}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -337,7 +360,7 @@ const TicketDetailPage = () => {
|
|||||||
disabled={statusProcessing}
|
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"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -349,18 +372,18 @@ const TicketDetailPage = () => {
|
|||||||
disabled={statusProcessing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
<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">
|
<div className="mt-4 space-y-4">
|
||||||
{ticket.responses.length === 0 ? (
|
{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">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
ticket.responses.map((response) => {
|
ticket.responses.map((response) => {
|
||||||
@@ -375,15 +398,15 @@ const TicketDetailPage = () => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
<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 && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{response.isInternal && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-500">{formatDateTime(response.createdAt)}</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">
|
<ul className="mt-3 flex flex-wrap gap-2 text-sm text-blue-600">
|
||||||
{response.attachments.map((attachment) => (
|
{response.attachments.map((attachment) => (
|
||||||
<li key={attachment.id}>
|
<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">
|
<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">
|
||||||
📎 {attachment.filename}
|
<span className="truncate max-w-[200px] sm:max-w-[300px] md:max-w-none" title={attachment.filename}>
|
||||||
|
📎 {attachment.filename}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -410,11 +435,11 @@ const TicketDetailPage = () => {
|
|||||||
|
|
||||||
{ticket.status !== 'closed' && (
|
{ticket.status !== 'closed' && (
|
||||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
<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
|
<textarea
|
||||||
value={reply}
|
value={reply}
|
||||||
onChange={(event) => setReply(event.target.value)}
|
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"
|
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}
|
rows={6}
|
||||||
/>
|
/>
|
||||||
@@ -427,7 +452,7 @@ const TicketDetailPage = () => {
|
|||||||
onChange={(event) => setIsInternalNote(event.target.checked)}
|
onChange={(event) => setIsInternalNote(event.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
Внутренний комментарий (видно только операторам)
|
{isEn ? 'Internal comment (visible only to operators)' : 'Внутренний комментарий (видно только операторам)'}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -438,7 +463,7 @@ const TicketDetailPage = () => {
|
|||||||
disabled={sending || reply.length === 0}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -446,7 +471,7 @@ const TicketDetailPage = () => {
|
|||||||
disabled={sending || !reply.trim()}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import AuthContext from '../../../context/authcontext';
|
import AuthContext from '../../../context/authcontext';
|
||||||
import apiClient from '../../../utils/apiClient';
|
import apiClient from '../../../utils/apiClient';
|
||||||
import { useToast } from '../../../hooks/useToast';
|
import { useToast } from '../../../hooks/useToast';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
|
||||||
interface TicketAuthor {
|
interface TicketAuthor {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -66,7 +67,7 @@ interface TicketStats {
|
|||||||
unassigned?: number;
|
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' },
|
open: { label: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||||
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||||
awaiting_reply: { label: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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 TicketsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { userData } = useContext(AuthContext);
|
const { userData } = useContext(AuthContext);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
const isOperator = Boolean(userData?.user?.operator);
|
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 [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||||
const [meta, setMeta] = useState<TicketListMeta>({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false });
|
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 });
|
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 diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMinutes < 1) return 'только что';
|
if (diffMinutes < 1) return isEn ? 'just now' : 'только что';
|
||||||
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
|
if (diffMinutes < 60) return isEn ? `${diffMinutes} min ago` : `${diffMinutes} мин назад`;
|
||||||
if (diffHours < 24) return `${diffHours} ч назад`;
|
if (diffHours < 24) return isEn ? `${diffHours} h ago` : `${diffHours} ч назад`;
|
||||||
if (diffDays < 7) return `${diffDays} дн назад`;
|
if (diffDays < 7) return isEn ? `${diffDays} d ago` : `${diffDays} дн назад`;
|
||||||
return date.toLocaleDateString('ru-RU');
|
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusCards = useMemo(() => {
|
const statusCards = useMemo(() => {
|
||||||
if (isOperator) {
|
if (isOperator) {
|
||||||
return [
|
return [
|
||||||
{ title: 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
{ title: isEn ? 'Open' : 'Открытые', 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: isEn ? 'Awaiting Reply' : 'Ожидают ответа', 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: isEn ? 'Assigned to me' : 'Назначены мне', 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 ? 'Unassigned' : 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ title: 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
{ title: isEn ? 'Active' : 'Активные', 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: isEn ? 'Awaiting Reply' : 'Ожидают ответа', 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 ? '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) => {
|
const handleChangePage = (nextPage: number) => {
|
||||||
setMeta((prev) => ({ ...prev, page: nextPage }));
|
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="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 className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Тикеты поддержки</h1>
|
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||||
<p className="text-gray-600">Создавайте обращения и следите за их обработкой в режиме реального времени.</p>
|
<p className="text-gray-600">{isEn ? 'Create tickets and track their processing in real time.' : 'Создавайте обращения и следите за их обработкой в режиме реального времени.'}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/dashboard/tickets/new')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -240,69 +261,69 @@ const TicketsPage = () => {
|
|||||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
|
||||||
<div className="lg:col-span-2">
|
<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
|
<select
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
|
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"
|
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="all">{isEn ? 'All statuses' : 'Все статусы'}</option>
|
||||||
<option value="open">Открыт</option>
|
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||||
<option value="in_progress">В работе</option>
|
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||||
<option value="awaiting_reply">Ожидает ответа</option>
|
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||||
<option value="resolved">Решён</option>
|
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||||
<option value="closed">Закрыт</option>
|
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-2">
|
<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
|
<select
|
||||||
value={filters.category}
|
value={filters.category}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, category: event.target.value }))}
|
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"
|
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="all">{isEn ? 'All categories' : 'Все категории'}</option>
|
||||||
<option value="general">Общие вопросы</option>
|
<option value="general">{isEn ? 'General' : 'Общие вопросы'}</option>
|
||||||
<option value="technical">Технические</option>
|
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||||
<option value="billing">Биллинг</option>
|
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||||
<option value="other">Другое</option>
|
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-2">
|
<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
|
<select
|
||||||
value={filters.priority}
|
value={filters.priority}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
|
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"
|
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="all">{isEn ? 'All priorities' : 'Все приоритеты'}</option>
|
||||||
<option value="urgent">Срочно</option>
|
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||||
<option value="high">Высокий</option>
|
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||||
<option value="normal">Обычный</option>
|
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||||
<option value="low">Низкий</option>
|
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{isOperator && (
|
{isOperator && (
|
||||||
<div className="lg:col-span-2">
|
<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
|
<select
|
||||||
value={filters.assigned}
|
value={filters.assigned}
|
||||||
onChange={(event) => setFilters((prev) => ({ ...prev, assigned: event.target.value }))}
|
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"
|
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="all">{isEn ? 'All' : 'Все'}</option>
|
||||||
<option value="me">Мои тикеты</option>
|
<option value="me">{isEn ? 'My tickets' : 'Мои тикеты'}</option>
|
||||||
<option value="unassigned">Без оператора</option>
|
<option value="unassigned">{isEn ? 'Unassigned' : 'Без оператора'}</option>
|
||||||
<option value="others">Назначены другим</option>
|
<option value="others">{isEn ? 'Assigned to others' : 'Назначены другим'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={isOperator ? 'lg:col-span-4' : 'lg:col-span-6'}>
|
<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
|
<input
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(event) => setSearchInput(event.target.value)}
|
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"
|
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>
|
</div>
|
||||||
@@ -313,29 +334,29 @@ const TicketsPage = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
<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" />
|
<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>
|
</div>
|
||||||
) : tickets.length === 0 ? (
|
) : tickets.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
<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">
|
<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>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/tickets/new"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</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">
|
<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>ID</span>
|
||||||
<span>Тема</span>
|
<span>{isEn ? 'Subject' : 'Тема'}</span>
|
||||||
<span>Статус</span>
|
<span>{isEn ? 'Status' : 'Статус'}</span>
|
||||||
<span>Приоритет</span>
|
<span>{isEn ? 'Priority' : 'Приоритет'}</span>
|
||||||
<span>Обновлён</span>
|
<span>{isEn ? 'Updated' : 'Обновлён'}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-gray-100">
|
<ul className="divide-y divide-gray-100">
|
||||||
{tickets.map((ticket) => {
|
{tickets.map((ticket) => {
|
||||||
@@ -358,7 +379,7 @@ const TicketsPage = () => {
|
|||||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{ticket.message}</p>
|
<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">
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||||
{ticket.assignedOperator && (
|
{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}
|
{ticket.assignedOperator.username}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -367,7 +388,7 @@ const TicketsPage = () => {
|
|||||||
{ticket.responseCount}
|
{ticket.responseCount}
|
||||||
</span>
|
</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 ?? 'Неизвестно'}
|
{ticket.user?.username ?? 'Неизвестно'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import React, { useState } from 'react';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import apiClient from '../../../utils/apiClient';
|
import apiClient from '../../../utils/apiClient';
|
||||||
import { useToast } from '../../../hooks/useToast';
|
import { useToast } from '../../../hooks/useToast';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
|
||||||
const NewTicketPage: React.FC = () => {
|
const NewTicketPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
message: '',
|
message: '',
|
||||||
@@ -19,7 +22,7 @@ const NewTicketPage: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!formData.title.trim() || !formData.message.trim()) {
|
if (!formData.title.trim() || !formData.message.trim()) {
|
||||||
setError('Заполните все поля');
|
setError(isEn ? 'Please fill in all fields' : 'Заполните все поля');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +33,12 @@ const NewTicketPage: React.FC = () => {
|
|||||||
const response = await apiClient.post('/api/ticket/create', formData);
|
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}`);
|
navigate(`/dashboard/tickets/${response.data.ticket.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка создания тикета:', err);
|
console.error('Ошибка создания тикета:', err);
|
||||||
setError('Не удалось создать тикет. Попробуйте ещё раз.');
|
setError(isEn ? 'Failed to create ticket. Please try again.' : 'Не удалось создать тикет. Попробуйте ещё раз.');
|
||||||
addToast('Не удалось создать тикет', 'error');
|
addToast(isEn ? 'Failed to create ticket' : 'Не удалось создать тикет', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
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"
|
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
|
||||||
>
|
>
|
||||||
<span>←</span>
|
<span>←</span>
|
||||||
<span>Назад к тикетам</span>
|
<span>{isEn ? 'Back to tickets' : 'Назад к тикетам'}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="bg-white rounded-xl shadow-md p-8">
|
<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 && (
|
{error && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
|
<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 */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
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"
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -84,34 +87,34 @@ const NewTicketPage: React.FC = () => {
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Категория
|
{isEn ? 'Category' : 'Категория'}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.category}
|
value={formData.category}
|
||||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
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"
|
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="general">{isEn ? 'General Questions' : 'Общие вопросы'}</option>
|
||||||
<option value="technical">Технические</option>
|
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||||
<option value="billing">Биллинг</option>
|
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||||
<option value="other">Другое</option>
|
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Приоритет
|
{isEn ? 'Priority' : 'Приоритет'}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
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"
|
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="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||||
<option value="normal">Обычный</option>
|
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||||
<option value="high">Высокий</option>
|
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||||
<option value="urgent">Срочно</option>
|
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,29 +122,29 @@ const NewTicketPage: React.FC = () => {
|
|||||||
{/* Message */}
|
{/* Message */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<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>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
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"
|
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}
|
rows={8}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<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">
|
<ul className="text-sm text-blue-800 space-y-1">
|
||||||
<li>• Укажите все детали проблемы</li>
|
<li>• {isEn ? 'Include all details of the issue' : 'Укажите все детали проблемы'}</li>
|
||||||
<li>• Приложите скриншоты, если возможно</li>
|
<li>• {isEn ? 'Attach screenshots if possible' : 'Приложите скриншоты, если возможно'}</li>
|
||||||
<li>• Опишите шаги для воспроизведения ошибки</li>
|
<li>• {isEn ? 'Describe steps to reproduce the error' : 'Опишите шаги для воспроизведения ошибки'}</li>
|
||||||
<li>• Среднее время ответа: 2-4 часа</li>
|
<li>• {isEn ? 'Average response time: 2-4 hours' : 'Среднее время ответа: 2-4 часа'}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,14 +154,14 @@ const NewTicketPage: React.FC = () => {
|
|||||||
to="/dashboard/tickets"
|
to="/dashboard/tickets"
|
||||||
className="px-6 py-3 text-gray-700 hover:text-gray-900 font-medium transition-colors"
|
className="px-6 py-3 text-gray-700 hover:text-gray-900 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Отмена
|
{isEn ? 'Cancel' : 'Отмена'}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 { Link } from 'react-router-dom';
|
||||||
import { FaCloud, FaShieldAlt, FaDatabase } from 'react-icons/fa';
|
import { FaCloud, FaShieldAlt, FaDatabase } from 'react-icons/fa';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Hero Section */}
|
||||||
<section className="relative bg-gradient-to-b from-blue-100 to-white pt-24 pb-32">
|
<section className="relative bg-white pt-24 pb-32 overflow-hidden">
|
||||||
<div className="container mx-auto text-center px-4">
|
{/* Light gradient background */}
|
||||||
<h1 className="text-5xl md:text-6xl font-extrabold leading-tight tracking-tighter text-gray-900">
|
<div className="absolute inset-0">
|
||||||
Облачное хранилище <br /> для ваших данных
|
<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>
|
</h1>
|
||||||
<p className="mt-6 text-lg md:text-xl max-w-2xl mx-auto text-gray-700">
|
<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 }}>
|
||||||
S3-совместимое хранилище с высокой доступностью и надежностью. Храните файлы, резервные копии и медиа-контент.
|
{t('home.hero.description')}
|
||||||
</p>
|
</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
|
<Link
|
||||||
to="/register"
|
to={localePath('/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"
|
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>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to={localePath('/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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section className="py-20 px-4">
|
<section className="py-20 px-4 bg-white">
|
||||||
<div className="container mx-auto">
|
<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="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">
|
<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>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-center text-gray-900">S3 API</h3>
|
<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-700">
|
<p className="mt-2 text-center text-gray-600">
|
||||||
Полная совместимость с Amazon S3 API. Используйте привычные инструменты и SDK.
|
{t('home.features.s3Compatible.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-center text-gray-900">Масштабируемость</h3>
|
<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-700">
|
<p className="mt-2 text-center text-gray-600">
|
||||||
Неограниченное хранилище. Платите только за используемое пространство.
|
{t('home.features.speed.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-center text-gray-900">Надежность и безопасность</h3>
|
<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-700">
|
<p className="mt-2 text-center text-gray-600">
|
||||||
Шифрование данных, резервное копирование и высокая доступность 99.9%.
|
{t('home.features.reliability.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,20 +88,29 @@ const HomePage = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Call to Action Section */}
|
{/* Call to Action Section */}
|
||||||
<section className="bg-gray-800 py-20 px-4 text-white text-center">
|
<section className="bg-gradient-to-r from-gray-900 to-gray-800 py-20 px-4 text-white text-center relative overflow-hidden">
|
||||||
<h2 className="text-4xl md:text-5xl font-bold leading-tight">
|
{/* Subtle pattern */}
|
||||||
Готовы начать?
|
<div className="absolute inset-0 opacity-5">
|
||||||
</h2>
|
<div className="absolute inset-0" style={{
|
||||||
<p className="mt-4 text-lg md:text-xl max-w-2xl mx-auto text-gray-400">
|
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")`
|
||||||
Присоединяйтесь к разработчикам, которые доверяют нам свои данные.
|
}} />
|
||||||
</p>
|
</div>
|
||||||
<div className="mt-8">
|
|
||||||
<Link
|
<div className="relative z-10">
|
||||||
to="/register"
|
<h2 className="text-4xl md:text-5xl font-bold leading-tight animate-fade-in-up">
|
||||||
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"
|
{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 }}>
|
||||||
</Link>
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Turnstile } from '@marsidev/react-turnstile';
|
|||||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
import { API_URL } from '../config/api';
|
import { API_URL } from '../config/api';
|
||||||
import QRLogin from '../components/QRLogin';
|
import QRLogin from '../components/QRLogin';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const [loginMethod, setLoginMethod] = useState<'password' | 'qr'>('password');
|
const [loginMethod, setLoginMethod] = useState<'password' | 'qr'>('password');
|
||||||
@@ -18,36 +20,48 @@ const LoginPage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { login, isLoggedIn } = useAuth();
|
const { login, isLoggedIn } = useAuth();
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||||
|
|
||||||
// Если уже авторизован — редирект на dashboard
|
// Redirect if logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoggedIn) {
|
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 params = new URLSearchParams(location.search);
|
||||||
const token = params.get('token');
|
const token = params.get('token');
|
||||||
const authError = params.get('error');
|
const authError = params.get('error');
|
||||||
|
const qrParam = params.get('qr');
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
login(token);
|
login(token);
|
||||||
navigate('/dashboard', { replace: true });
|
navigate(localePath('/dashboard'), { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authError) {
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
if (!turnstileToken) {
|
if (!turnstileToken) {
|
||||||
setError('Пожалуйста, подтвердите, что вы не робот.');
|
setError(locale === 'en'
|
||||||
|
? 'Please confirm you are not a robot.'
|
||||||
|
: 'Пожалуйста, подтвердите, что вы не робот.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,22 +73,24 @@ const LoginPage = () => {
|
|||||||
turnstileToken: turnstileToken,
|
turnstileToken: turnstileToken,
|
||||||
});
|
});
|
||||||
login(response.data.token);
|
login(response.data.token);
|
||||||
// Возврат на исходную страницу, если был редирект
|
// Return to original page if redirected
|
||||||
type LocationState = { from?: { pathname?: string } };
|
type LocationState = { from?: { pathname?: string } };
|
||||||
const state = location.state as LocationState | null;
|
const state = location.state as LocationState | null;
|
||||||
const from = state?.from?.pathname || '/dashboard';
|
const from = state?.from?.pathname || localePath('/dashboard');
|
||||||
navigate(from);
|
navigate(from);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Сброс капчи при ошибке
|
// Reset captcha on error
|
||||||
if (turnstileRef.current) {
|
if (turnstileRef.current) {
|
||||||
turnstileRef.current.reset();
|
turnstileRef.current.reset();
|
||||||
}
|
}
|
||||||
setTurnstileToken(null);
|
setTurnstileToken(null);
|
||||||
|
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
if (axios.isAxiosError(err) && err.response) {
|
||||||
setError(err.response.data.message || 'Неизвестная ошибка входа.');
|
setError(err.response.data.message || (locale === 'en' ? 'Unknown login error.' : 'Неизвестная ошибка входа.'));
|
||||||
} else {
|
} else {
|
||||||
setError('Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
setError(locale === 'en'
|
||||||
|
? 'Network error. Please try again later.'
|
||||||
|
: 'Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -88,9 +104,9 @@ const LoginPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<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">
|
<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">
|
<div className="flex mb-6 bg-gray-100 rounded-full p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -101,7 +117,7 @@ const LoginPage = () => {
|
|||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Пароль
|
{t('auth.login.password')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -112,7 +128,7 @@ const LoginPage = () => {
|
|||||||
: 'text-gray-600 hover:text-gray-900'
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
QR-код
|
{locale === 'en' ? 'QR Code' : 'QR-код'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,7 +139,7 @@ const LoginPage = () => {
|
|||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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"
|
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
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -132,7 +148,7 @@ const LoginPage = () => {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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"
|
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
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -146,7 +162,9 @@ const LoginPage = () => {
|
|||||||
onSuccess={(token: string) => setTurnstileToken(token)}
|
onSuccess={(token: string) => setTurnstileToken(token)}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setTurnstileToken(null);
|
setTurnstileToken(null);
|
||||||
setError('Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
setError(locale === 'en'
|
||||||
|
? 'Captcha loading error. Try refreshing the page.'
|
||||||
|
: 'Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
||||||
}}
|
}}
|
||||||
onExpire={() => setTurnstileToken(null)}
|
onExpire={() => setTurnstileToken(null)}
|
||||||
/>
|
/>
|
||||||
@@ -157,7 +175,9 @@ const LoginPage = () => {
|
|||||||
disabled={isLoading || !turnstileToken}
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -167,17 +187,17 @@ const LoginPage = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<QRLogin onSuccess={() => navigate('/dashboard')} />
|
<QRLogin onSuccess={() => navigate(localePath('/dashboard'))} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Социальные сети */}
|
{/* Social login */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +206,7 @@ const LoginPage = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOAuthLogin('google')}
|
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"
|
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" />
|
<img src="/google.png" alt="Google" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -195,7 +215,7 @@ const LoginPage = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOAuthLogin('github')}
|
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"
|
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" />
|
<img src="/github.png" alt="GitHub" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -204,7 +224,7 @@ const LoginPage = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOAuthLogin('yandex')}
|
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"
|
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" />
|
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -213,9 +233,9 @@ const LoginPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-gray-600">
|
<p className="mt-6 text-gray-600">
|
||||||
Нет аккаунта?{' '}
|
{t('auth.login.noAccount')}{' '}
|
||||||
<Link to="/register" className="text-ospab-primary font-bold hover:underline">
|
<Link to={localePath('/register')} className="text-ospab-primary font-bold hover:underline">
|
||||||
Зарегистрироваться
|
{t('auth.register.title')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PageTmpl from '../components/pagetempl';
|
import PageTmpl from '../components/pagetempl';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
|
||||||
const Privacy: React.FC = () => {
|
const Privacy: React.FC = () => {
|
||||||
|
const { locale } = useTranslation();
|
||||||
|
const isEn = locale === 'en';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTmpl>
|
<PageTmpl>
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<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">
|
<div className="prose prose-lg max-w-none">
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const QRLoginPage = () => {
|
|||||||
const [status, setStatus] = useState<'loading' | 'confirm' | 'success' | 'error' | 'expired'>('loading');
|
const [status, setStatus] = useState<'loading' | 'confirm' | 'success' | 'error' | 'expired'>('loading');
|
||||||
const [message, setMessage] = useState('Проверка QR-кода...');
|
const [message, setMessage] = useState('Проверка QR-кода...');
|
||||||
const [userData, setUserData] = useState<UserData | null>(null);
|
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');
|
const code = searchParams.get('code');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -24,6 +26,30 @@ const QRLoginPage = () => {
|
|||||||
return;
|
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 () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
@@ -50,12 +76,31 @@ const QRLoginPage = () => {
|
|||||||
setUserData(userResponse.data.user);
|
setUserData(userResponse.data.user);
|
||||||
setStatus('confirm');
|
setStatus('confirm');
|
||||||
setMessage('Подтвердите вход на новом устройстве');
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка проверки авторизации:', error);
|
console.error('Ошибка проверки авторизации:', error);
|
||||||
|
|
||||||
if (isAxiosError(error) && error.response?.status === 401) {
|
if (isAxiosError(error) && error.response?.status === 401) {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
|
setMessage('Вы не авторизованы. Войдите в аккаунт на телефоне');
|
||||||
|
} else if (isAxiosError(error) && error.response?.status === 500) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('Серверная ошибка при проверке QR-кода');
|
||||||
} else {
|
} else {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage('Ошибка проверки авторизации');
|
setMessage('Ошибка проверки авторизации');
|
||||||
@@ -64,6 +109,10 @@ const QRLoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer);
|
||||||
|
};
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
@@ -102,10 +151,6 @@ const QRLoginPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
window.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = () => {
|
const getIcon = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'loading':
|
case 'loading':
|
||||||
@@ -161,30 +206,61 @@ const QRLoginPage = () => {
|
|||||||
|
|
||||||
{status === 'confirm' && userData && (
|
{status === 'confirm' && userData && (
|
||||||
<div className="mb-6">
|
<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-gray-600 mb-2">Войти на новом устройстве как:</p>
|
||||||
<p className="text-xl font-bold text-gray-900">{userData.username}</p>
|
<p className="text-xl font-bold text-gray-900">{userData.username}</p>
|
||||||
<p className="text-sm text-gray-500">{userData.email}</p>
|
<p className="text-sm text-gray-500">{userData.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-sm mb-6">
|
{/* Device info */}
|
||||||
Это вы пытаетесь войти? Подтвердите вход на компьютере
|
<div className="mb-4 text-sm text-gray-600">
|
||||||
</p>
|
<div className="mb-2">Детали запроса:</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-gray-100 text-xs text-gray-700">
|
||||||
<div className="flex gap-3">
|
<div>IP: <span className="font-medium">{requestInfo?.ip ?? '—'}</span></div>
|
||||||
<button
|
<div className="mt-1">Device: <span className="font-medium">{requestInfo?.ua ?? '—'}</span></div>
|
||||||
onClick={handleCancel}
|
</div>
|
||||||
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>
|
|
||||||
</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>
|
</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 { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Turnstile } from '@marsidev/react-turnstile';
|
import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
@@ -6,11 +6,16 @@ import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
|||||||
import useAuth from '../context/useAuth';
|
import useAuth from '../context/useAuth';
|
||||||
import { API_URL } from '../config/api';
|
import { API_URL } from '../config/api';
|
||||||
import { useToast } from '../hooks/useToast';
|
import { useToast } from '../hooks/useToast';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
import { validateEmail } from '../utils/emailValidation';
|
||||||
|
|
||||||
const RegisterPage = () => {
|
const RegisterPage = () => {
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
|
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -19,10 +24,36 @@ const RegisterPage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
|
|
||||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const token = params.get('token');
|
const token = params.get('token');
|
||||||
@@ -30,48 +61,57 @@ const RegisterPage = () => {
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
login(token);
|
login(token);
|
||||||
navigate('/dashboard', { replace: true });
|
navigate(localePath('/dashboard'), { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authError) {
|
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) => {
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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) {
|
if (!turnstileToken) {
|
||||||
setError('Пожалуйста, подтвердите, что вы не робот.');
|
setError(t('auth.register.captchaRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`${API_URL}/api/auth/register`, {
|
await axios.post(`${API_URL}/api/auth/register`, {
|
||||||
username: username,
|
username: username,
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
turnstileToken: turnstileToken,
|
turnstileToken: turnstileToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
addToast('Регистрация прошла успешно! Теперь вы можете войти.', 'success');
|
addToast(t('auth.register.success'), 'success');
|
||||||
navigate('/login');
|
navigate(localePath('/login'));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Сброс капчи при ошибке
|
// Reset captcha on error
|
||||||
if (turnstileRef.current) {
|
if (turnstileRef.current) {
|
||||||
turnstileRef.current.reset();
|
turnstileRef.current.reset();
|
||||||
}
|
}
|
||||||
setTurnstileToken(null);
|
setTurnstileToken(null);
|
||||||
|
|
||||||
if (axios.isAxiosError(err) && err.response) {
|
if (axios.isAxiosError(err) && err.response) {
|
||||||
const errorMsg = err.response.data.message || 'Неизвестная ошибка регистрации.';
|
const errorMsg = err.response.data.message || t('auth.register.unknownError');
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
} else {
|
} else {
|
||||||
setError('Произошла ошибка сети. Пожалуйста, попробуйте позже.');
|
setError(t('auth.register.networkError'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -85,27 +125,50 @@ const RegisterPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<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">
|
<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}>
|
<form onSubmit={handleRegister}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(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"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(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"
|
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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
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"
|
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
|
required
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -119,7 +182,7 @@ const RegisterPage = () => {
|
|||||||
onSuccess={(token: string) => setTurnstileToken(token)}
|
onSuccess={(token: string) => setTurnstileToken(token)}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setTurnstileToken(null);
|
setTurnstileToken(null);
|
||||||
setError('Ошибка загрузки капчи. Попробуйте обновить страницу.');
|
setError(t('auth.register.captchaError'));
|
||||||
}}
|
}}
|
||||||
onExpire={() => setTurnstileToken(null)}
|
onExpire={() => setTurnstileToken(null)}
|
||||||
/>
|
/>
|
||||||
@@ -127,24 +190,24 @@ const RegisterPage = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Социальные сети */}
|
{/* Social networks */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-sm">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,9 +248,9 @@ const RegisterPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-gray-600">
|
<p className="mt-6 text-gray-600">
|
||||||
Уже есть аккаунт?{' '}
|
{t('auth.register.haveAccount')}{' '}
|
||||||
<Link to="/login" className="text-ospab-primary font-bold hover:underline">
|
<Link to={localePath('/login')} className="text-ospab-primary font-bold hover:underline">
|
||||||
Войти
|
{t('auth.register.loginLink')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { API_URL } from '../config/api';
|
import { API_URL } from '../config/api';
|
||||||
|
import { useTranslation } from '../i18n';
|
||||||
|
import { useLocalePath } from '../middleware';
|
||||||
|
|
||||||
type StoragePlanDto = {
|
type StoragePlanDto = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -35,23 +37,33 @@ type DecoratedPlan = StoragePlanDto & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TIER_LABELS = ['Developer', 'Team', 'Scale', 'Enterprise'];
|
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 S3PlansPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const localePath = useLocalePath();
|
||||||
const [plans, setPlans] = useState<StoragePlanDto[]>([]);
|
const [plans, setPlans] = useState<StoragePlanDto[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectingPlan, setSelectingPlan] = 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -61,14 +73,14 @@ const S3PlansPage = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const response = await fetch(`${API_URL}/api/storage/plans`);
|
const response = await fetch(`${API_URL}/api/storage/plans`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Не удалось загрузить тарифы');
|
throw new Error(locale === 'en' ? 'Failed to load plans' : 'Не удалось загрузить тарифы');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPlans(Array.isArray(data?.plans) ? data.plans : []);
|
setPlans(Array.isArray(data?.plans) ? data.plans : []);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Ошибка загрузки тарифов';
|
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Error loading plans' : 'Ошибка загрузки тарифов');
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(message);
|
setError(message);
|
||||||
}
|
}
|
||||||
@@ -150,9 +162,9 @@ const S3PlansPage = () => {
|
|||||||
if (!cartId) {
|
if (!cartId) {
|
||||||
throw new Error('Ответ сервера без идентификатора корзины');
|
throw new Error('Ответ сервера без идентификатора корзины');
|
||||||
}
|
}
|
||||||
navigate(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`);
|
navigate(localePath(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Не удалось начать оплату';
|
const message = err instanceof Error ? err.message : (locale === 'en' ? 'Failed to start payment' : 'Не удалось начать оплату');
|
||||||
setError(message);
|
setError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSelectingPlan(null);
|
setSelectingPlan(null);
|
||||||
@@ -168,21 +180,22 @@ const S3PlansPage = () => {
|
|||||||
<span>S3 Object Storage</span>
|
<span>S3 Object Storage</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-6 text-gray-900">
|
<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>
|
</h1>
|
||||||
<p className="text-lg sm:text-xl text-gray-600 max-w-3xl mx-auto mb-8">
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center gap-4 text-sm text-gray-500">
|
<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">
|
<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>
|
||||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
<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
|
<FaLock className="text-emerald-500" /> AES-256 at-rest
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<FaBolt className="text-2xl text-blue-600" />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-gray-50 rounded-xl">
|
<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">
|
<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" />
|
<FaShieldAlt className="text-2xl text-green-600" />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-gray-50 rounded-xl">
|
<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">
|
<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" />
|
<FaCloud className="text-2xl text-purple-600" />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +261,7 @@ const S3PlansPage = () => {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">{section.label} Tier</h2>
|
<h2 className="text-2xl font-semibold text-gray-900">{section.label} Tier</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Подберите план по объёму хранилища и включённому трафику
|
{locale === 'en' ? 'Choose a plan by storage volume and included traffic' : 'Подберите план по объёму хранилища и включённому трафику'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@@ -263,21 +282,21 @@ const S3PlansPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<span className="text-4xl font-bold text-gray-900">₽{plan.price.toLocaleString('ru-RU')}</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">в месяц</span>
|
<span className="text-gray-500 ml-2 text-sm">{locale === 'en' ? 'per month' : 'в месяц'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 text-sm mb-6">
|
<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">
|
<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>
|
<span className="font-semibold text-gray-900">{formatMetric(plan.quotaGb, 'GB')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
<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>
|
<span className="font-semibold text-gray-900">{formatMetric(plan.bandwidthGb, 'GB')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
<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>
|
<span className="font-semibold text-gray-900">{plan.requestLimit}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,11 +325,11 @@ const S3PlansPage = () => {
|
|||||||
{selectingPlan === plan.code ? (
|
{selectingPlan === plan.code ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<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 />
|
<FaArrowRight />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -326,8 +345,8 @@ const S3PlansPage = () => {
|
|||||||
{customPlan && customPlanCalculated && (
|
{customPlan && customPlanCalculated && (
|
||||||
<div className="mt-20 pt-20 border-t border-gray-200">
|
<div className="mt-20 pt-20 border-t border-gray-200">
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Кастомный тариф</h2>
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">{locale === 'en' ? 'Custom Plan' : 'Кастомный тариф'}</h2>
|
||||||
<p className="text-gray-600">Укажите нужное количество GB и получите автоматический расчёт стоимости</p>
|
<p className="text-gray-600">{locale === 'en' ? 'Specify the required amount of GB and get automatic cost calculation' : 'Укажите нужное количество GB и получите автоматический расчёт стоимости'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-8 border border-blue-200">
|
<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 */}
|
{/* Input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-gray-900 mb-4">
|
<label className="block text-sm font-semibold text-gray-900 mb-4">
|
||||||
Сколько GB вам нужно?
|
{locale === 'en' ? 'How many GB do you need?' : 'Сколько GB вам нужно?'}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<input
|
<input
|
||||||
@@ -359,7 +378,7 @@ const S3PlansPage = () => {
|
|||||||
<span className="text-lg font-semibold text-gray-900 min-w-fit">GB</span>
|
<span className="text-lg font-semibold text-gray-900 min-w-fit">GB</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 mt-2">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -373,24 +392,24 @@ const S3PlansPage = () => {
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
})}
|
})}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2 text-sm mb-6">
|
<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">
|
<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">
|
<span className="font-semibold text-gray-900">
|
||||||
{customPlanCalculated.quotaGb.toLocaleString('ru-RU')} GB
|
{customPlanCalculated.quotaGb.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
<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">
|
<span className="font-semibold text-gray-900">
|
||||||
{customPlanCalculated.bandwidthGb.toLocaleString('ru-RU')} GB
|
{customPlanCalculated.bandwidthGb.toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} GB
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
<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">
|
<span className="font-semibold text-gray-900">
|
||||||
{customPlanCalculated.requestLimit}
|
{customPlanCalculated.requestLimit}
|
||||||
</span>
|
</span>
|
||||||
@@ -410,11 +429,11 @@ const S3PlansPage = () => {
|
|||||||
{selectingPlan === customPlan.code ? (
|
{selectingPlan === customPlan.code ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
<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 />
|
<FaArrowRight />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -427,13 +446,13 @@ const S3PlansPage = () => {
|
|||||||
|
|
||||||
<div className="mt-20 text-center">
|
<div className="mt-20 text-center">
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Нужна гибридная архитектура или больше 5 PB хранения?
|
{locale === 'en' ? 'Need hybrid architecture or more than 5 PB storage?' : 'Нужна гибридная архитектура или больше 5 PB хранения?'}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:sales@ospab.host"
|
href="mailto:sales@ospab.host"
|
||||||
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
|
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
|
||||||
>
|
>
|
||||||
Связаться с командой продаж
|
{locale === 'en' ? 'Contact sales team' : 'Связаться с командой продаж'}
|
||||||
<FaArrowRight />
|
<FaArrowRight />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,25 +462,31 @@ const S3PlansPage = () => {
|
|||||||
<section className="py-20 px-6 sm:px-8 bg-white">
|
<section className="py-20 px-6 sm:px-8 bg-white">
|
||||||
<div className="container mx-auto max-w-6xl">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
|
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
|
||||||
Подходит для любых сценариев
|
{locale === 'en' ? 'Suitable for any scenario' : 'Подходит для любых сценариев'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<div className="p-6 bg-gray-50 rounded-xl">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-gray-50 rounded-xl">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-gray-50 rounded-xl">
|
<div className="p-6 bg-gray-50 rounded-xl">
|
||||||
<h3 className="text-lg font-semibold mb-3">SaaS & Data Lake</h3>
|
<h3 className="text-lg font-semibold mb-3">SaaS & Data Lake</h3>
|
||||||
<p className="text-gray-600 text-sm mb-4">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,22 +495,24 @@ const S3PlansPage = () => {
|
|||||||
|
|
||||||
<section className="py-20 px-6 sm:px-8 bg-gray-900 text-white">
|
<section className="py-20 px-6 sm:px-8 bg-gray-900 text-white">
|
||||||
<div className="container mx-auto max-w-4xl text-center">
|
<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">
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
<Link
|
<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"
|
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>
|
||||||
<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"
|
className="px-8 py-4 bg-blue-500 text-white rounded-lg font-semibold hover:bg-blue-400 transition-colors"
|
||||||
>
|
>
|
||||||
Войти
|
{t('nav.login')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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