diff --git a/ospabhost/NGINX_DEPLOY.md b/ospabhost/NGINX_DEPLOY.md new file mode 100644 index 0000000..075d4bd --- /dev/null +++ b/ospabhost/NGINX_DEPLOY.md @@ -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` diff --git a/ospabhost/backend/prisma/schema.prisma b/ospabhost/backend/prisma/schema.prisma index cae1d78..42d367c 100644 --- a/ospabhost/backend/prisma/schema.prisma +++ b/ospabhost/backend/prisma/schema.prisma @@ -13,33 +13,35 @@ datasource db { // VPS/Server models removed - moving to S3 storage model User { - id Int @id @default(autoincrement()) - username String - email String @unique - password String - createdAt DateTime @default(now()) + id Int @id @default(autoincrement()) + username String + email String @unique + password String + createdAt DateTime @default(now()) // plans Plan[] @relation("UserPlans") - operator Int @default(0) - isAdmin Boolean @default(false) - tickets Ticket[] @relation("UserTickets") - responses Response[] @relation("OperatorResponses") - checks Check[] @relation("UserChecks") - balance Float @default(0) - notifications Notification[] + operator Int @default(0) + isAdmin Boolean @default(false) + tickets Ticket[] @relation("UserTickets") + responses Response[] @relation("OperatorResponses") + checks Check[] @relation("UserChecks") + balance Float @default(0) + notifications Notification[] pushSubscriptions PushSubscription[] - transactions Transaction[] // История всех транзакций - posts Post[] @relation("PostAuthor") // Статьи блога - comments Comment[] @relation("UserComments") // Комментарии - buckets StorageBucket[] // S3 хранилища пользователя - checkoutSessions StorageCheckoutSession[] - + transactions Transaction[] // История всех транзакций + posts Post[] @relation("PostAuthor") // Статьи блога + comments Comment[] @relation("UserComments") // Комментарии + buckets StorageBucket[] // S3 хранилища пользователя + checkoutSessions StorageCheckoutSession[] + // Список промокодов, использованных пользователем + usedPromoCodes PromoCode[] + // Новые relations для расширенных настроек - sessions Session[] - loginHistory LoginHistory[] - apiKeys APIKey[] - notificationSettings NotificationSettings? - profile UserProfile? - qrLoginRequests QrLoginRequest[] + sessions Session[] + loginHistory LoginHistory[] + apiKeys APIKey[] + notificationSettings NotificationSettings? + profile UserProfile? + qrLoginRequests QrLoginRequest[] @@map("user") } @@ -53,48 +55,47 @@ model Check { createdAt DateTime @default(now()) user User @relation("UserChecks", fields: [userId], references: [id]) -@@map("check") + @@map("check") } - model Service { - id Int @id @default(autoincrement()) - name String @unique - price Float + id Int @id @default(autoincrement()) + name String @unique + price Float // planId Int? // plan Plan? @relation("PlanServices", fields: [planId], references: [id]) -@@map("service") + @@map("service") } model Ticket { - id Int @id @default(autoincrement()) - title String - message String @db.Text - userId Int - status String @default("open") // open, in_progress, awaiting_reply, resolved, closed - priority String @default("normal") // low, normal, high, urgent - category String @default("general") // general, technical, billing, other - assignedTo Int? // ID оператора, которому назначен тикет - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - closedAt DateTime? - responses Response[] @relation("TicketResponses") + id Int @id @default(autoincrement()) + title String + message String @db.Text + userId Int + status String @default("open") // open, in_progress, awaiting_reply, resolved, closed + priority String @default("normal") // low, normal, high, urgent + category String @default("general") // general, technical, billing, other + assignedTo Int? // ID оператора, которому назначен тикет + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + closedAt DateTime? + responses Response[] @relation("TicketResponses") attachments TicketAttachment[] - user User? @relation("UserTickets", fields: [userId], references: [id]) + user User? @relation("UserTickets", fields: [userId], references: [id]) @@map("ticket") } model Response { - id Int @id @default(autoincrement()) - ticketId Int - operatorId Int - message String @db.Text - isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам) - createdAt DateTime @default(now()) - ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade) - operator User @relation("OperatorResponses", fields: [operatorId], references: [id]) + id Int @id @default(autoincrement()) + ticketId Int + operatorId Int + message String @db.Text + isInternal Boolean @default(false) // Внутренний комментарий (виден только операторам) + createdAt DateTime @default(now()) + ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id], onDelete: Cascade) + operator User @relation("OperatorResponses", fields: [operatorId], references: [id]) attachments ResponseAttachment[] @@map("response") @@ -102,17 +103,17 @@ model Response { // Прикреплённые файлы к тикетам model TicketAttachment { - id Int @id @default(autoincrement()) - ticketId Int - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) - - filename String - fileUrl String - fileSize Int // Размер в байтах - mimeType String - + id Int @id @default(autoincrement()) + ticketId Int + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + + filename String + fileUrl String + fileSize Int // Размер в байтах + mimeType String + createdAt DateTime @default(now()) - + @@map("ticket_attachment") } @@ -121,32 +122,32 @@ model ResponseAttachment { id Int @id @default(autoincrement()) responseId Int response Response @relation(fields: [responseId], references: [id], onDelete: Cascade) - - filename String - fileUrl String - fileSize Int - mimeType String - - createdAt DateTime @default(now()) - + + filename String + fileUrl String + fileSize Int + mimeType String + + createdAt DateTime @default(now()) + @@map("response_attachment") } // QR-код авторизация (как в Telegram Web) model QrLoginRequest { - id Int @id @default(autoincrement()) - code String @unique @db.VarChar(128) // Уникальный код QR - userId Int? // После подтверждения - ID пользователя - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - - status String @default("pending") // pending, confirmed, expired, rejected - ipAddress String? - userAgent String? @db.Text - - createdAt DateTime @default(now()) - expiresAt DateTime // Через 60 секунд - confirmedAt DateTime? - + id Int @id @default(autoincrement()) + code String @unique @db.VarChar(128) // Уникальный код QR + userId Int? // После подтверждения - ID пользователя + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + status String @default("pending") // pending, confirmed, expired, rejected + ipAddress String? + userAgent String? @db.Text + + createdAt DateTime @default(now()) + expiresAt DateTime // Через 60 секунд + confirmedAt DateTime? + @@index([code]) @@index([status, expiresAt]) @@map("qr_login_request") @@ -154,34 +155,34 @@ model QrLoginRequest { // История всех транзакций (пополнения, списания, возвраты) model Transaction { - id Int @id @default(autoincrement()) - userId Int - amount Float - type String // deposit (пополнение), withdrawal (списание), refund (возврат) - description String + id Int @id @default(autoincrement()) + userId Int + amount Float + type String // deposit (пополнение), withdrawal (списание), refund (возврат) + description String balanceBefore Float balanceAfter Float - adminId Int? // ID админа, если операция выполнена админом - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + adminId Int? // ID админа, если операция выполнена админом + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) @@map("transaction") } // Блог model Post { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - content String @db.Text // Rich text content (HTML) - excerpt String? @db.Text // Краткое описание для ленты - coverImage String? // URL обложки - url String @unique // Пользовательский URL (blog_name) - status String @default("draft") // draft, published, archived + content String @db.Text // Rich text content (HTML) + excerpt String? @db.Text // Краткое описание для ленты + coverImage String? // URL обложки + url String @unique // Пользовательский URL (blog_name) + status String @default("draft") // draft, published, archived authorId Int - author User @relation("PostAuthor", fields: [authorId], references: [id]) - views Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + author User @relation("PostAuthor", fields: [authorId], references: [id]) + views Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt publishedAt DateTime? comments Comment[] @@ -190,41 +191,41 @@ model Post { // Комментарии к статьям блога model Comment { - id Int @id @default(autoincrement()) - postId Int - userId Int? // null если комментарий от гостя - authorName String? // Имя автора (для гостей) - content String @db.Text - status String @default("pending") // pending, approved, rejected - createdAt DateTime @default(now()) - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - user User? @relation("UserComments", fields: [userId], references: [id]) + id Int @id @default(autoincrement()) + postId Int + userId Int? // null если комментарий от гостя + authorName String? // Имя автора (для гостей) + content String @db.Text + status String @default("pending") // pending, approved, rejected + createdAt DateTime @default(now()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + user User? @relation("UserComments", fields: [userId], references: [id]) @@map("comment") } // Модель для уведомлений model Notification { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low - title String - message String @db.Text - + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type String // server_created, payment_charged, tariff_expiring, ticket_reply, payment_received, balance_low + title String + message String @db.Text + // Связанные сущности (опционально) - ticketId Int? - checkId Int? - + ticketId Int? + checkId Int? + // Метаданные - actionUrl String? // URL для перехода при клике - icon String? // Иконка (emoji или path) - color String? // Цвет (green, blue, orange, red, purple) - + actionUrl String? // URL для перехода при клике + icon String? // Иконка (emoji или path) + color String? // Цвет (green, blue, orange, red, purple) + isRead Boolean @default(false) createdAt DateTime @default(now()) - + @@index([userId, isRead]) @@index([userId, createdAt]) @@map("notification") @@ -232,19 +233,19 @@ model Notification { // Модель для Push-подписок (Web Push API) model PushSubscription { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - endpoint String @db.VarChar(512) - p256dh String @db.Text // Публичный ключ для шифрования - auth String @db.Text // Токен аутентификации - + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + endpoint String @db.VarChar(512) + p256dh String @db.Text // Публичный ключ для шифрования + auth String @db.Text // Токен аутентификации + userAgent String? @db.Text // Браузер/устройство - + createdAt DateTime @default(now()) lastUsed DateTime @default(now()) - + @@unique([userId, endpoint]) @@index([userId]) @@map("push_subscription") @@ -252,40 +253,40 @@ model PushSubscription { // Активные сеансы пользователя model Session { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - token String @unique @db.VarChar(500) // JWT refresh token + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + token String @unique @db.VarChar(500) // JWT refresh token ipAddress String? - userAgent String? @db.Text - device String? // Desktop, Mobile, Tablet - browser String? // Chrome, Firefox, Safari, etc. - location String? // Город/страна по IP - + userAgent String? @db.Text + device String? // Desktop, Mobile, Tablet + browser String? // Chrome, Firefox, Safari, etc. + location String? // Город/страна по IP + lastActivity DateTime @default(now()) createdAt DateTime @default(now()) expiresAt DateTime - + @@index([userId]) @@map("session") } // История входов model LoginHistory { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ipAddress String - userAgent String? @db.Text + userAgent String? @db.Text device String? browser String? location String? - + success Boolean @default(true) // true = успешный вход, false = неудачная попытка createdAt DateTime @default(now()) - + @@index([userId]) @@index([createdAt]) @@map("login_history") @@ -293,20 +294,20 @@ model LoginHistory { // API ключи для разработчиков model APIKey { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - name String // Название (например, "Production API") - key String @unique @db.VarChar(64) // Сам API ключ - prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx) - - permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.] - - lastUsed DateTime? - createdAt DateTime @default(now()) - expiresAt DateTime? - + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + name String // Название (например, "Production API") + key String @unique @db.VarChar(64) // Сам API ключ + prefix String @db.VarChar(16) // Префикс для отображения (ospab_xxxx) + + permissions String? @db.Text // JSON массив разрешений ["servers:read", "servers:create", etc.] + + lastUsed DateTime? + createdAt DateTime @default(now()) + expiresAt DateTime? + @@index([userId]) @@index([key]) @@map("api_key") @@ -317,95 +318,95 @@ model NotificationSettings { id Int @id @default(autoincrement()) userId Int @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + // Email уведомления - emailBalanceLow Boolean @default(true) - emailPaymentCharged Boolean @default(true) - emailTicketReply Boolean @default(true) - emailNewsletter Boolean @default(false) - + emailBalanceLow Boolean @default(true) + emailPaymentCharged Boolean @default(true) + emailTicketReply Boolean @default(true) + emailNewsletter Boolean @default(false) + // Push уведомления - pushBalanceLow Boolean @default(true) - pushPaymentCharged Boolean @default(true) - pushTicketReply Boolean @default(true) - + pushBalanceLow Boolean @default(true) + pushPaymentCharged Boolean @default(true) + pushTicketReply Boolean @default(true) + updatedAt DateTime @updatedAt - + @@map("notification_settings") } // Настройки профиля model UserProfile { - id Int @id @default(autoincrement()) - userId Int @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - avatarUrl String? // Путь к аватару + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + avatarUrl String? // Путь к аватару phoneNumber String? - timezone String? @default("Europe/Moscow") - language String? @default("ru") - + timezone String? @default("Europe/Moscow") + language String? @default("ru") + // Настройки приватности profilePublic Boolean @default(false) showEmail Boolean @default(false) - + // 2FA twoFactorEnabled Boolean @default(false) twoFactorSecret String? @db.Text - - updatedAt DateTime @updatedAt - + + updatedAt DateTime @updatedAt + @@map("user_profile") } // S3 Bucket модель model StorageBucket { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - name String // Уникальное имя бакета в рамках пользователя - plan String // Код тарифа из StoragePlan - quotaGb Int // Лимит включённого объёма в GB - usedBytes BigInt @default(0) // Текущий объём хранения в байтах - objectCount Int @default(0) - storageClass String @default("standard") // standard, infrequent, archive - region String @default("ru-central-1") - public Boolean @default(false) - versioning Boolean @default(false) - status String @default("active") // active, grace, suspended - monthlyPrice Float - nextBillingDate DateTime? - lastBilledAt DateTime? - autoRenew Boolean @default(true) - usageSyncedAt DateTime? - storagePlan StoragePlan? @relation(fields: [plan], references: [code]) - regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code]) - storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code]) - consoleCredential StorageConsoleCredential? + name String // Уникальное имя бакета в рамках пользователя + plan String // Код тарифа из StoragePlan + quotaGb Int // Лимит включённого объёма в GB + usedBytes BigInt @default(0) // Текущий объём хранения в байтах + objectCount Int @default(0) + storageClass String @default("standard") // standard, infrequent, archive + region String @default("ru-central-1") + public Boolean @default(false) + versioning Boolean @default(false) + status String @default("active") // active, grace, suspended + monthlyPrice Float + nextBillingDate DateTime? + lastBilledAt DateTime? + autoRenew Boolean @default(true) + usageSyncedAt DateTime? + storagePlan StoragePlan? @relation(fields: [plan], references: [code]) + regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code]) + storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code]) + consoleCredential StorageConsoleCredential? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - accessKeys StorageAccessKey[] + accessKeys StorageAccessKey[] + @@unique([userId, name]) // Имя уникально в рамках пользователя @@index([userId]) @@index([region]) @@index([storageClass]) - @@unique([userId, name]) // Имя уникально в рамках пользователя @@map("storage_bucket") } model StorageAccessKey { - id Int @id @default(autoincrement()) - bucketId Int - bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + bucketId Int + bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade) - accessKey String @unique - secretKey String // хранится в зашифрованном виде + accessKey String @unique + secretKey String // хранится в зашифрованном виде label String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) lastUsedAt DateTime? @@index([bucketId]) @@ -413,36 +414,36 @@ model StorageAccessKey { } model StorageConsoleCredential { - id Int @id @default(autoincrement()) - bucketId Int @unique - bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + bucketId Int @unique + bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade) - login String - passwordHash String + login String + passwordHash String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю) @@map("storage_console_credential") } model StoragePlan { - id Int @id @default(autoincrement()) - code String @unique - name String - price Float - pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа - bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения - requestsPerGb Int? // Количество операций на 1 GB хранения - quotaGb Int // Базовая квота для обычных тарифов (0 для custom) - bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom) - requestLimit String // Текстовое описание лимита операций - order Int @default(0) - isActive Boolean @default(true) - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + id Int @id @default(autoincrement()) + code String @unique + name String + price Float + pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа + bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения + requestsPerGb Int? // Количество операций на 1 GB хранения + quotaGb Int // Базовая квота для обычных тарифов (0 для custom) + bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom) + requestLimit String // Текстовое описание лимита операций + order Int @default(0) + isActive Boolean @default(true) + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt buckets StorageBucket[] checkoutSessions StorageCheckoutSession[] @@ -451,22 +452,22 @@ model StoragePlan { } model StorageCheckoutSession { - id String @id @default(uuid()) + id String @id @default(uuid()) userId Int? - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) planId Int plan StoragePlan @relation(fields: [planId], references: [id]) planCode String planName String planDescription String? price Float - promoCodeId Int? - promoCode PromoCode? @relation(fields: [promoCodeId], references: [id]) - promoDiscount Float? @default(0) + promoCodeId Int? + promoCode PromoCode? @relation(fields: [promoCodeId], references: [id]) + promoDiscount Float? @default(0) quotaGb Int bandwidthGb Int requestLimit String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) expiresAt DateTime consumedAt DateTime? @@ -486,40 +487,43 @@ model StorageRegion { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - buckets StorageBucket[] @relation("BucketRegion") + buckets StorageBucket[] @relation("BucketRegion") @@map("storage_region") } model StorageClass { - id Int @id @default(autoincrement()) - code String @unique - name String - description String? - redundancy String? - performance String? - retrievalFee String? - isDefault Boolean @default(false) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + code String @unique + name String + description String? + redundancy String? + performance String? + retrievalFee String? + isDefault Boolean @default(false) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - buckets StorageBucket[] @relation("BucketClass") + buckets StorageBucket[] @relation("BucketClass") @@map("storage_class") } model PromoCode { - id Int @id @default(autoincrement()) - code String @unique - amount Float // discount amount in RUB - used Boolean @default(false) - usedBy Int? + id Int @id @default(autoincrement()) + code String @unique + amount Float // discount amount in RUB + used Boolean @default(false) + usedBy Int? usedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [usedBy], references: [id]) + user User? @relation(fields: [usedBy], references: [id], onDelete: SetNull) + + // Обратная связь для корзин, в которые применяли этот промокод + checkoutSessions StorageCheckoutSession[] @@map("promo_code") -} \ No newline at end of file +} diff --git a/ospabhost/backend/src/index.ts b/ospabhost/backend/src/index.ts index 981d959..48c6208 100644 --- a/ospabhost/backend/src/index.ts +++ b/ospabhost/backend/src/index.ts @@ -111,15 +111,72 @@ app.get('/', async (req, res) => { // ==================== SITEMAP ==================== app.get('/sitemap.xml', (req, res) => { const baseUrl = 'https://ospab.host'; - - const staticPages = [ - { loc: '/', priority: '1.0', changefreq: 'weekly', description: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов' }, - { loc: '/about', priority: '0.9', changefreq: 'monthly', description: 'О компании - Современная платформа хранения данных' }, - { loc: '/login', priority: '0.7', changefreq: 'monthly', description: 'Вход в панель управления с QR-аутентификацией' }, - { loc: '/register', priority: '0.8', changefreq: 'monthly', description: 'Регистрация аккаунта - Начните за 2 минуты' }, - { loc: '/blog', priority: '0.85', changefreq: 'daily', description: 'Блог о S3 хранилище и хостинге' }, - { loc: '/terms', priority: '0.5', changefreq: 'yearly', description: 'Условия использования сервиса' }, - { loc: '/privacy', priority: '0.5', changefreq: 'yearly', description: 'Политика конфиденциальности и защита данных' }, + + const pages = [ + // Главная страница + { + loc: '/', + priority: '1.0', + changefreq: 'weekly', + ru: { title: 'Ospab Host - Облачное S3 хранилище и хостинг сайтов', description: 'Надёжное облачное S3-совместимое хранилище в Великом Новгороде' }, + en: { title: 'Ospab Host - Cloud S3 Storage and Website Hosting', description: 'Reliable S3-compatible cloud storage in Veliky Novgorod' } + }, + // О компании + { + loc: '/about', + priority: '0.9', + changefreq: 'monthly', + ru: { title: 'О компании - Современная платформа хранения данных', description: 'Узнайте о ospab.host - платформе облачного хранилища в Великом Новгороде' }, + en: { title: 'About Us - Modern Data Storage Platform', description: 'Learn about ospab.host - cloud storage platform in Veliky Novgorod' } + }, + // Вход + { + loc: '/login', + priority: '0.7', + changefreq: 'monthly', + ru: { title: 'Вход в панель управления с QR-аутентификацией', description: 'Войдите в ваш личный кабинет ospab.host' }, + en: { title: 'Login to Control Panel with QR Authentication', description: 'Sign in to your ospab.host account' } + }, + // Регистрация + { + loc: '/register', + priority: '0.8', + changefreq: 'monthly', + ru: { title: 'Регистрация аккаунта - Начните за 2 минуты', description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем' }, + en: { title: 'Account Registration - Start in 2 Minutes', description: 'Register with ospab.host and start using cloud storage' } + }, + // Блог + { + loc: '/blog', + priority: '0.85', + changefreq: 'daily', + ru: { title: 'Блог о S3 хранилище и хостинге', description: 'Статьи о хостинге, S3 хранилище и облачных технологиях' }, + en: { title: 'Blog about S3 Storage and Hosting', description: 'Articles about hosting, S3 storage and cloud technologies' } + }, + // Тарифы + { + loc: '/tariffs', + priority: '0.9', + changefreq: 'weekly', + ru: { title: 'Тарифы на облачное S3 хранилище', description: 'Выберите подходящий тариф для вашего проекта' }, + en: { title: 'Cloud S3 Storage Plans', description: 'Choose the right plan for your project' } + }, + // Условия использования + { + loc: '/terms', + priority: '0.5', + changefreq: 'yearly', + ru: { title: 'Условия использования сервиса', description: 'Правила и условия использования ospab.host' }, + en: { title: 'Terms of Service', description: 'Rules and conditions for using ospab.host' } + }, + // Политика конфиденциальности + { + loc: '/privacy', + priority: '0.5', + changefreq: 'yearly', + ru: { title: 'Политика конфиденциальности и защита данных', description: 'Как мы защищаем ваши данные и обеспечиваем конфиденциальность' }, + en: { title: 'Privacy Policy and Data Protection', description: 'How we protect your data and ensure privacy' } + } ]; let xml = '\n'; @@ -127,12 +184,25 @@ app.get('/sitemap.xml', (req, res) => { const lastmod = new Date().toISOString().split('T')[0]; - for (const page of staticPages) { + for (const page of pages) { + // Русская версия (без префикса) xml += ' \n'; xml += ` ${baseUrl}${page.loc}\n`; xml += ` ${lastmod}\n`; xml += ` ${page.priority}\n`; xml += ` ${page.changefreq}\n`; + xml += ' \n'; + xml += ' \n'; + xml += ' \n'; + + // Английская версия (с префиксом /en) + xml += ' \n'; + xml += ` ${baseUrl}/en${page.loc}\n`; + xml += ` ${lastmod}\n`; + xml += ` ${page.priority}\n`; + xml += ` ${page.changefreq}\n`; + xml += ' \n'; + xml += ' \n'; xml += ' \n'; } @@ -145,20 +215,29 @@ app.get('/sitemap.xml', (req, res) => { // ==================== ROBOTS.TXT ==================== app.get('/robots.txt', (req, res) => { const robots = `# ospab Host - Облачное S3 хранилище и хостинг -# Хранение данных, техподдержка 24/7 +# Cloud S3 Storage and Website Hosting User-agent: * Allow: / Allow: /about +Allow: /en/about Allow: /login +Allow: /en/login Allow: /register +Allow: /en/register Allow: /blog +Allow: /en/blog Allow: /blog/* +Allow: /en/blog/* +Allow: /tariffs +Allow: /en/tariffs Allow: /terms +Allow: /en/terms Allow: /privacy +Allow: /en/privacy Allow: /uploads/blog -# Запрет индексации приватных разделов +# Disallow private sections / Запрет индексации приватных разделов Disallow: /dashboard Disallow: /dashboard/* Disallow: /api/ @@ -171,7 +250,7 @@ Disallow: /uploads/checks Sitemap: https://ospab.host/sitemap.xml -# Поисковые роботы +# Search engine robots / Поисковые роботы User-agent: Googlebot Allow: / Crawl-delay: 0 diff --git a/ospabhost/backend/src/modules/account/account.controller.ts b/ospabhost/backend/src/modules/account/account.controller.ts index 7286cf7..4f4376a 100644 --- a/ospabhost/backend/src/modules/account/account.controller.ts +++ b/ospabhost/backend/src/modules/account/account.controller.ts @@ -10,12 +10,18 @@ import { } from './account.service'; import { prisma } from '../../prisma/client'; +// Хелпер для извлечения сообщения из ошибки +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return getErrorMessage(error); + return String(error); +} + /** * Получить информацию о текущем пользователе */ export const getAccountInfo = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -33,7 +39,7 @@ export const getAccountInfo = async (req: Request, res: Response) => { */ export const requestPasswordChangeHandler = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -71,7 +77,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response) }); } catch (error: unknown) { console.error('Ошибка запроса смены пароля:', error); - res.status(500).json({ error: error.message || 'Ошибка сервера' }); + res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' }); } }; @@ -80,7 +86,7 @@ export const requestPasswordChangeHandler = async (req: Request, res: Response) */ export const confirmPasswordChangeHandler = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -99,7 +105,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response) }); } catch (error: unknown) { console.error('Ошибка подтверждения смены пароля:', error); - res.status(400).json({ error: error.message || 'Ошибка подтверждения' }); + res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' }); } }; @@ -108,7 +114,7 @@ export const confirmPasswordChangeHandler = async (req: Request, res: Response) */ export const requestUsernameChangeHandler = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -139,7 +145,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response) }); } catch (error: unknown) { console.error('Ошибка запроса смены имени:', error); - res.status(400).json({ error: error.message || 'Ошибка сервера' }); + res.status(400).json({ error: getErrorMessage(error) || 'Ошибка сервера' }); } }; @@ -148,7 +154,7 @@ export const requestUsernameChangeHandler = async (req: Request, res: Response) */ export const confirmUsernameChangeHandler = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -167,7 +173,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response) }); } catch (error: unknown) { console.error('Ошибка подтверждения смены имени:', error); - res.status(400).json({ error: error.message || 'Ошибка подтверждения' }); + res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' }); } }; @@ -176,7 +182,7 @@ export const confirmUsernameChangeHandler = async (req: Request, res: Response) */ export const requestAccountDeletionHandler = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -189,7 +195,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response) }); } catch (error: unknown) { console.error('Ошибка запроса удаления аккаунта:', error); - res.status(500).json({ error: error.message || 'Ошибка сервера' }); + res.status(500).json({ error: getErrorMessage(error) || 'Ошибка сервера' }); } }; @@ -198,7 +204,7 @@ export const requestAccountDeletionHandler = async (req: Request, res: Response) */ export const confirmAccountDeletionHandler = async (req: Request, res: Response) => { try { - const userId = (req.user as any)?.id; + const userId = (req.user as { id?: number })?.id; if (!userId) { return res.status(401).json({ error: 'Не авторизован' }); } @@ -217,7 +223,8 @@ export const confirmAccountDeletionHandler = async (req: Request, res: Response) }); } catch (error: unknown) { console.error('Ошибка подтверждения удаления аккаунта:', error); - res.status(400).json({ error: error.message || 'Ошибка подтверждения' }); + res.status(400).json({ error: getErrorMessage(error) || 'Ошибка подтверждения' }); } }; + diff --git a/ospabhost/backend/src/modules/admin/admin.controller.ts b/ospabhost/backend/src/modules/admin/admin.controller.ts index f584328..0e33b93 100644 --- a/ospabhost/backend/src/modules/admin/admin.controller.ts +++ b/ospabhost/backend/src/modules/admin/admin.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { prisma } from '../../prisma/client'; import { createNotification } from '../notification/notification.controller'; +import { sendNotificationEmail } from '../notification/email.service'; function toNumeric(value: unknown): number { if (typeof value === 'bigint') { @@ -514,23 +515,50 @@ export class AdminController { return res.status(404).json({ error: 'Пользователь не найден' }); } + if (!user.email) { + return res.status(400).json({ error: 'У пользователя не указан email' }); + } + const now = new Date().toISOString(); const logMsg = `[Admin] EMAIL-TEST | userId=${user.id} | username=${user.username} | email=${user.email} | time=${now}`; console.log(logMsg); - // Здесь должна быть реальная отправка email (имитация) - await new Promise(resolve => setTimeout(resolve, 800)); + // Отправляем реальное email уведомление + const emailResult = await sendNotificationEmail({ + to: user.email, + username: user.username, + title: 'Тестовое уведомление', + message: 'Это тестовое email-уведомление от ospab.host. Если вы получили это письмо, email-уведомления настроены корректно.', + actionUrl: '/dashboard/notifications', + type: 'test_email' + }); + + if (emailResult.status === 'error') { + return res.status(500).json({ + success: false, + error: `Ошибка отправки email: ${emailResult.message}`, + details: { userId: user.id, email: user.email, time: now } + }); + } + + if (emailResult.status === 'skipped') { + return res.status(400).json({ + success: false, + error: 'SMTP не настроен. Укажите SMTP_USER и SMTP_PASS в переменных окружения.', + details: { userId: user.id, email: user.email, time: now } + }); + } return res.json({ success: true, - message: 'Email-уведомление успешно отправлено (тест)', + message: 'Email-уведомление успешно отправлено', details: { userId: user.id, username: user.username, email: user.email, type: 'email', time: now, - status: 'sent (mock)' + messageId: 'messageId' in emailResult ? emailResult.messageId : undefined } }); } catch (error) { diff --git a/ospabhost/backend/src/modules/qr-auth/qr-auth.controller.ts b/ospabhost/backend/src/modules/qr-auth/qr-auth.controller.ts index 145d9dd..a20c2f0 100644 --- a/ospabhost/backend/src/modules/qr-auth/qr-auth.controller.ts +++ b/ospabhost/backend/src/modules/qr-auth/qr-auth.controller.ts @@ -4,7 +4,7 @@ import crypto from 'crypto'; import { createSession } from '../session/session.controller'; import { logger } from '../../utils/logger'; -const QR_EXPIRATION_SECONDS = 60; // QR-код живёт 60 секунд +const QR_EXPIRATION_SECONDS = 180; // QR-код живёт 180 секунд (3 минуты) // Генерировать уникальный код для QR function generateQRCode(): string { @@ -14,7 +14,7 @@ function generateQRCode(): string { // Создать новый QR-запрос для логина export async function createQRLoginRequest(req: Request, res: Response) { try { - const code = generateQRCode(); + const code = generateQRCode(); const ipAddress = req.headers['x-forwarded-for'] as string || req.socket.remoteAddress || ''; const userAgent = req.headers['user-agent'] || ''; @@ -31,6 +31,16 @@ export async function createQRLoginRequest(req: Request, res: Response) { } }); + // Ensure QR creation is visible in production logs: write directly to stdout + console.log('[QR Create] Создан QR-запрос', JSON.stringify({ + code: qrRequest.code, + ipAddress: qrRequest.ipAddress, + userAgent: qrRequest.userAgent?.toString?.().slice(0, 200), + host: req.headers.host, + origin: req.headers.origin, + referer: req.headers.referer + })); + res.json({ code: qrRequest.code, expiresAt: qrRequest.expiresAt, @@ -47,21 +57,38 @@ export async function checkQRStatus(req: Request, res: Response) { try { const { code } = req.params; + // Log incoming status checks for tracing + logger.debug('[QR Status] Проверка статуса QR', { + code, + ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, + ua: (req.headers['user-agent'] || '').toString().slice(0, 200) + }); + const qrRequest = await prisma.qrLoginRequest.findUnique({ where: { code } }); if (!qrRequest) { + // Log as error so it appears in production logs — include host/origin/referer and remote IP for tracing + logger.error('[QR Status] QR-код не найден', { + code, + host: req.headers.host, + origin: req.headers.origin, + referer: req.headers.referer, + ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress + }); return res.status(404).json({ error: 'QR-код не найден' }); } // Проверяем истёк ли QR-код - if (new Date() > qrRequest.expiresAt) { + const now = new Date(); + const expiresIn = Math.max(0, Math.ceil((qrRequest.expiresAt.getTime() - now.getTime()) / 1000)); + if (expiresIn <= 0) { await prisma.qrLoginRequest.update({ where: { code }, data: { status: 'expired' } }); - return res.json({ status: 'expired' }); + return res.json({ status: 'expired', expiresAt: qrRequest.expiresAt, expiresIn: 0 }); } // Если подтверждён, создаём сессию и возвращаем токен @@ -85,8 +112,12 @@ export async function checkQRStatus(req: Request, res: Response) { // Создаём сессию для нового устройства const { token } = await createSession(user.id, req); - // Удаляем использованный QR-запрос - await prisma.qrLoginRequest.delete({ where: { code } }); + // Попытка безопасно удалить использованный QR-запрос (deleteMany не бросает если записи не найдено) + try { + await prisma.qrLoginRequest.deleteMany({ where: { code } }); + } catch (err) { + logger.warn('[QR Status] Не удалось удалить QR-запрос (возможно уже удалён)', { code, error: err }); + } return res.json({ status: 'confirmed', @@ -102,7 +133,7 @@ export async function checkQRStatus(req: Request, res: Response) { }); } - res.json({ status: qrRequest.status }); + return res.json({ status: qrRequest.status, expiresAt: qrRequest.expiresAt, expiresIn, ipAddress: qrRequest.ipAddress ?? undefined, userAgent: qrRequest.userAgent ? (qrRequest.userAgent as string).slice(0, 200) : undefined }); } catch (error) { logger.error('Ошибка проверки статуса QR:', error); res.status(500).json({ error: 'Ошибка проверки статуса' }); @@ -266,3 +297,61 @@ export async function cleanupExpiredQRRequests() { logger.error('[QR Cleanup] Ошибка:', error); } } + +// DEV-only: получить последние N QR-запросов (для отладки) +export async function listRecentQRRequests(req: Request, res: Response) { + try { + // In production allow only requests from localhost (for safe debugging) + if (process.env.NODE_ENV === 'production') { + const remote = req.socket.remoteAddress || ''; + const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1'); + if (!isLocal) { + return res.status(404).json({ error: 'Not found' }); + } + } + + const limit = Math.min(100, Number(req.query.limit) || 50); + const rows = await prisma.qrLoginRequest.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + code: true, + status: true, + ipAddress: true, + userAgent: true, + createdAt: true, + expiresAt: true, + userId: true + } + }); + + res.json({ count: rows.length, rows }); + } catch (error) { + logger.error('[QR Debug] Ошибка получения списка QR-запросов:', error); + res.status(500).json({ error: 'Ошибка получения списка' }); + } +} + +// DEV-only: получить QR-запрос по коду +export async function getQRRequestByCode(req: Request, res: Response) { + try { + // In production allow only requests from localhost (for safe debugging) + if (process.env.NODE_ENV === 'production') { + const remote = req.socket.remoteAddress || ''; + const isLocal = remote === '::1' || remote === '127.0.0.1' || remote.startsWith('::ffff:127.0.0.1'); + if (!isLocal) { + return res.status(404).json({ error: 'Not found' }); + } + } + + const { code } = req.params; + const row = await prisma.qrLoginRequest.findUnique({ where: { code } }); + if (!row) { + return res.status(404).json({ error: 'QR-код не найден' }); + } + res.json(row); + } catch (error) { + logger.error('[QR Debug] Ошибка получения QR по коду:', error); + res.status(500).json({ error: 'Ошибка получения' }); + } +} diff --git a/ospabhost/backend/src/modules/qr-auth/qr-auth.routes.ts b/ospabhost/backend/src/modules/qr-auth/qr-auth.routes.ts index cfe5409..62d15aa 100644 --- a/ospabhost/backend/src/modules/qr-auth/qr-auth.routes.ts +++ b/ospabhost/backend/src/modules/qr-auth/qr-auth.routes.ts @@ -4,7 +4,9 @@ import { checkQRStatus, confirmQRLogin, rejectQRLogin, - markQRAsScanning + markQRAsScanning, + listRecentQRRequests, + getQRRequestByCode } from './qr-auth.controller'; import { authMiddleware } from '../auth/auth.middleware'; @@ -16,6 +18,10 @@ router.post('/generate', createQRLoginRequest); // Проверить статус QR-кода (polling, публичный endpoint) router.get('/status/:code', checkQRStatus); +// DEV-only debug endpoints (возвращают информацию о последних QR-запросах) +router.get('/debug/list', listRecentQRRequests); +router.get('/debug/get/:code', getQRRequestByCode); + // Отметить что пользователь открыл страницу подтверждения (требует авторизации) router.post('/scanning', authMiddleware, markQRAsScanning); diff --git a/ospabhost/backend/src/modules/storage/storage.routes.ts b/ospabhost/backend/src/modules/storage/storage.routes.ts index f5df7af..f6c43bc 100644 --- a/ospabhost/backend/src/modules/storage/storage.routes.ts +++ b/ospabhost/backend/src/modules/storage/storage.routes.ts @@ -160,7 +160,8 @@ router.post('/cart/:id/apply-promo', authMiddleware, async (req, res) => { return res.json({ success: true, cart: result }); } catch (e: unknown) { const message = e instanceof Error ? e.message : 'Не удалось применить промокод'; - return res.status(400).json({ error: message }); + const status = typeof message === 'string' && message.includes('PromoCode модель недоступна') ? 500 : 400; + return res.status(status).json({ error: message }); } }); diff --git a/ospabhost/backend/src/modules/storage/storage.service.ts b/ospabhost/backend/src/modules/storage/storage.service.ts index d6d7bf7..29f2423 100644 --- a/ospabhost/backend/src/modules/storage/storage.service.ts +++ b/ospabhost/backend/src/modules/storage/storage.service.ts @@ -661,9 +661,13 @@ function buildPlanFromSession(session: CheckoutSessionRecord, plan?: StoragePlan isActive: true, }; + // Для custom тарифа всегда берём значения из сессии return { ...base, price: toPlainNumber(session.price), + quotaGb: session.quotaGb, + bandwidthGb: session.bandwidthGb, + requestLimit: session.requestLimit, }; } @@ -672,6 +676,8 @@ type CheckoutSessionPayload = { plan: ReturnType; price: number; expiresAt: string; + originalPrice?: number | null; + promoDiscount?: number | null; }; type CheckoutSessionResult = { @@ -694,11 +700,14 @@ function ensureSessionActive(session: CheckoutSessionRecord, userId: number): Ch } function toCheckoutPayload(session: CheckoutSessionRecord, plan?: StoragePlanRecord | null): CheckoutSessionPayload { + const original = plan ? Number(plan.price) : toPlainNumber(session.price); return { cartId: session.id, plan: buildPlanFromSession(session, plan), price: toPlainNumber(session.price), expiresAt: session.expiresAt.toISOString(), + originalPrice: original, + promoDiscount: session.promoDiscount ?? null, }; } diff --git a/ospabhost/frontend/src/App.tsx b/ospabhost/frontend/src/App.tsx index 7f15b5b..948cbe1 100644 --- a/ospabhost/frontend/src/App.tsx +++ b/ospabhost/frontend/src/App.tsx @@ -20,100 +20,206 @@ import ServerError from './pages/500'; import BadGateway from './pages/502'; import ServiceUnavailable from './pages/503'; import GatewayTimeout from './pages/504'; +import ErrorPage from './pages/errors'; +import NetworkError from './pages/errors/NetworkError'; import Privateroute from './components/privateroute'; import { AuthProvider } from './context/authcontext'; import { WebSocketProvider } from './context/WebSocketContext'; import ErrorBoundary from './components/ErrorBoundary'; import { ToastProvider } from './components/Toast'; -import { LocaleProvider } from './middleware'; +import { LocaleProvider, useLocale } from './middleware'; -// SEO конфиг для всех маршрутов +// SEO конфиг для всех маршрутов с поддержкой локализации const SEO_CONFIG: Record = { '/': { - title: 'Облачное S3 хранилище', - description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.', - keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage', - og: { - title: 'ospab.host - Облачное S3 хранилище', - description: 'S3-совместимое хранилище с поддержкой 24/7', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/', + ru: { + title: 'Облачное S3 хранилище', + description: 'ospab.host - надёжное облачное S3-совместимое хранилище в Великом Новгороде. Хранение файлов, резервные копии, медиа-контент. Тикеты поддержки 24/7, QR-аутентификация.', + keywords: 'хостинг, облачное хранилище, S3, хранение файлов, Великий Новгород, object storage', + og: { + title: 'ospab.host - Облачное S3 хранилище', + description: 'S3-совместимое хранилище с поддержкой 24/7', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/', + }, + }, + en: { + title: 'Cloud S3 Storage', + description: 'ospab.host - reliable cloud S3-compatible storage in Veliky Novgorod. File storage, backups, media content. 24/7 support tickets, QR authentication.', + keywords: 'hosting, cloud storage, S3, file storage, Veliky Novgorod, object storage', + og: { + title: 'ospab.host - Cloud S3 Storage', + description: 'S3-compatible storage with 24/7 support', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/', + }, }, }, '/about': { - title: 'О компании - Ospab Host', - description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.', - keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород', - og: { - title: 'О компании ospab.host', - description: 'Современная платформа облачного хранилища', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/about', + ru: { + title: 'О компании - Ospab Host', + description: 'Узнайте о ospab.host - современной платформе облачного хранилища в Великом Новгороде. S3-совместимое хранилище с тикетами поддержки. Основатель Георгий Сыралёв.', + keywords: 'об ospab, история хостинга, облачные решения, S3 хранилище, Великий Новгород', + og: { + title: 'О компании ospab.host', + description: 'Современная платформа облачного хранилища', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/about', + }, + }, + en: { + title: 'About Company - Ospab Host', + description: 'Learn about ospab.host - modern cloud storage platform in Veliky Novgorod. S3-compatible storage with support tickets. Founder Georgy Syralyov.', + keywords: 'about ospab, hosting history, cloud solutions, S3 storage, Veliky Novgorod', + og: { + title: 'About ospab.host company', + description: 'Modern cloud storage platform', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/about', + }, }, }, '/login': { - title: 'Вход в аккаунт - Ospab Host', - description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.', - keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления', - og: { - title: 'Вход в ospab.host', - description: 'Доступ к панели управления', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/login', + ru: { + title: 'Вход в аккаунт - Ospab Host', + description: 'Войдите в ваш личный кабинет ospab.host. Управляйте хранилищем, тикеты поддержки, QR-аутентификация для быстрого входа.', + keywords: 'вход в аккаунт, личный кабинет, ospab логин, вход в хостинг, QR вход, панель управления', + og: { + title: 'Вход в ospab.host', + description: 'Доступ к панели управления', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/login', + }, + }, + en: { + title: 'Login to Account - Ospab Host', + description: 'Log in to your ospab.host personal account. Manage storage, support tickets, QR authentication for quick login.', + keywords: 'login account, personal account, ospab login, hosting login, QR login, control panel', + og: { + title: 'Login to ospab.host', + description: 'Access to control panel', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/login', + }, }, }, '/register': { - title: 'Регистрация - Создать аккаунт', - description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.', - keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт', - og: { - title: 'Регистрация в ospab.host', - description: 'Создайте аккаунт и начните использовать S3 хранилище', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/register', + ru: { + title: 'Регистрация - Создать аккаунт', + description: 'Зарегистрируйтесь в ospab.host и начните пользоваться облачным хранилищем. Создайте аккаунт бесплатно за 2 минуты, получите доступ к S3 API и тикетам поддержки.', + keywords: 'регистрация, создать аккаунт, ospab регистрация, регистрация хостинга, новый аккаунт', + og: { + title: 'Регистрация в ospab.host', + description: 'Создайте аккаунт и начните использовать S3 хранилище', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/register', + }, + }, + en: { + title: 'Registration - Create Account', + description: 'Register with ospab.host and start using cloud storage. Create an account for free in 2 minutes, get access to S3 API and support tickets.', + keywords: 'registration, create account, ospab registration, hosting registration, new account', + og: { + title: 'Registration at ospab.host', + description: 'Create an account and start using S3 storage', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/register', + }, }, }, '/blog': { - title: 'Блог о хостинге и S3', - description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.', - keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage', - og: { - title: 'Блог ospab.host', - description: 'Статьи о хостинге и DevOps', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/blog', + ru: { + title: 'Блог о хостинге и S3', + description: 'Статьи о хостинге, S3 хранилище, облачных технологиях, DevOps практиках, безопасности. Полезные гайды от команды ospab.host.', + keywords: 'блог хостинг, S3 гайды, облачное хранилище, DevOps, object storage', + og: { + title: 'Блог ospab.host', + description: 'Статьи о хостинге и DevOps', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/blog', + }, + }, + en: { + title: 'Blog about Hosting and S3', + description: 'Articles about hosting, S3 storage, cloud technologies, DevOps practices, security. Useful guides from the ospab.host team.', + keywords: 'hosting blog, S3 guides, cloud storage, DevOps, object storage', + og: { + title: 'ospab.host Blog', + description: 'Articles about hosting and DevOps', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/blog', + }, }, }, '/terms': { - title: 'Условия использования', - description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.', - keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия', - og: { - title: 'Условия использования ospab.host', - description: 'Полные условия использования сервиса', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/terms', + ru: { + title: 'Условия использования', + description: 'Условия использования сервиса ospab.host. Ознакомьтесь с полными правилами для пользователей облачного хранилища.', + keywords: 'условия использования, пользовательское соглашение, правила использования, юридические условия', + og: { + title: 'Условия использования ospab.host', + description: 'Полные условия использования сервиса', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/terms', + }, + }, + en: { + title: 'Terms of Use', + description: 'Terms of use for ospab.host service. Read the complete rules for cloud storage users.', + keywords: 'terms of use, user agreement, usage rules, legal terms', + og: { + title: 'ospab.host Terms of Use', + description: 'Complete terms of service', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/terms', + }, }, }, '/privacy': { - title: 'Политика конфиденциальности', - description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.', - keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных', - og: { - title: 'Политика конфиденциальности ospab.host', - description: 'Защита ваших данных и приватности', - image: 'https://ospab.host/og-image.jpg', - url: 'https://ospab.host/privacy', + ru: { + title: 'Политика конфиденциальности', + description: 'Политика конфиденциальности ospab.host. Узнайте как мы защищаем ваши персональные данные, информацию об аккаунте и платежах. Соответствие GDPR.', + keywords: 'политика конфиденциальности, приватность, защита данных, GDPR, безопасность данных', + og: { + title: 'Политика конфиденциальности ospab.host', + description: 'Защита ваших данных и приватности', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/privacy', + }, + }, + en: { + title: 'Privacy Policy', + description: 'ospab.host privacy policy. Learn how we protect your personal data, account information and payments. GDPR compliance.', + keywords: 'privacy policy, privacy, data protection, GDPR, data security', + og: { + title: 'ospab.host Privacy Policy', + description: 'Protection of your data and privacy', + image: 'https://ospab.host/og-image.jpg', + url: 'https://ospab.host/privacy', + }, }, }, }; @@ -121,15 +227,21 @@ const SEO_CONFIG: Record { const pathname = location.pathname; // Получаем SEO данные для текущего маршрута, иначе используем дефолтные - const seoData = SEO_CONFIG[pathname] || { - title: 'ospab.host - облачный хостинг', - description: 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.', - keywords: 'хостинг, облачный хостинг, VPS, VDS', + const seoConfig = SEO_CONFIG[pathname]; + const seoData = seoConfig ? seoConfig[locale] : { + title: locale === 'en' ? 'ospab.host - cloud hosting' : 'ospab.host - облачный хостинг', + description: locale === 'en' + ? 'ospab.host - reliable cloud hosting and virtual machines in Veliky Novgorod.' + : 'ospab.host - надёжный облачный хостинг и виртуальные машины в Великом Новгороде.', + keywords: locale === 'en' + ? 'hosting, cloud hosting, VPS, VDS' + : 'хостинг, облачный хостинг, VPS, VDS', }; // Устанавливаем title @@ -186,7 +298,7 @@ function SEOUpdater() { // Скроллим вверх при навигации window.scrollTo(0, 0); - }, [location.pathname]); + }, [location.pathname, locale]); return null; } @@ -256,6 +368,15 @@ function App() { } /> } /> } /> + + {/* Cloudflare-style error page */} + } /> + } /> + + {/* Network service page - default for unknown hosts */} + } /> + } /> + } /> diff --git a/ospabhost/frontend/src/components/ErrorPage.tsx b/ospabhost/frontend/src/components/ErrorPage.tsx index db4f309..e078031 100644 --- a/ospabhost/frontend/src/components/ErrorPage.tsx +++ b/ospabhost/frontend/src/components/ErrorPage.tsx @@ -1,5 +1,7 @@ import { Link } from 'react-router-dom'; import type { ReactNode } from 'react'; +import { useTranslation } from '../i18n'; +import { useLocalePath } from '../middleware'; interface ErrorPageProps { code: string; @@ -38,10 +40,13 @@ export default function ErrorPage({ showBackButton = true, showHomeButton = true, }: ErrorPageProps) { + const { t } = useTranslation(); + const localePath = useLocalePath(); + return (
- {/* Код ошибки */} + {/* Error code */}

{code}

@@ -49,33 +54,33 @@ export default function ErrorPage({
- {/* Заголовок */} + {/* Title */}

{title}

- {/* Описание */} + {/* Description */}

{description}

- {/* Кнопки */} + {/* Buttons */}
{showHomeButton && ( - На главную + {t('errors.goHome')} )} {showLoginButton && ( - Войти + {t('nav.login')} )} @@ -84,16 +89,16 @@ export default function ErrorPage({ onClick={() => window.history.back()} className="w-full inline-flex items-center justify-center px-6 py-3 border-2 border-gray-300 text-base font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500" > - Назад + {t('common.back')} )}
- {/* Контактная информация (опционально) */} + {/* Contact info (optional) */} {(code === '500' || code === '503') && (

- Если проблема сохраняется, свяжитесь с нами:{' '} + {t('footer.contact')}:{' '} { const [unreadCount, setUnreadCount] = useState(0); @@ -10,6 +11,8 @@ const NotificationBell = () => { const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(false); const { subscribe, unsubscribe, isConnected } = useWebSocket(); + const { locale } = useTranslation(); + const isEn = locale === 'en'; // WebSocket обработчик событий // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -115,12 +118,19 @@ const NotificationBell = () => { const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); - if (diffInSeconds < 60) return 'только что'; - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`; - if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`; - if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`; - - return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); + if (isEn) { + if (diffInSeconds < 60) return 'just now'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} h ago`; + if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} d ago`; + return date.toLocaleDateString('en-US', { day: 'numeric', month: 'short' }); + } else { + if (diffInSeconds < 60) return 'только что'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} мин назад`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} ч назад`; + if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} д назад`; + return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); + } }; return ( @@ -129,7 +139,7 @@ const NotificationBell = () => {

)} diff --git a/ospabhost/frontend/src/components/QRLogin.tsx b/ospabhost/frontend/src/components/QRLogin.tsx index 6d9fe45..43f21cf 100644 --- a/ospabhost/frontend/src/components/QRLogin.tsx +++ b/ospabhost/frontend/src/components/QRLogin.tsx @@ -3,6 +3,7 @@ import { QRCodeSVG } from 'qrcode.react'; import { useNavigate } from 'react-router-dom'; import useAuth from '../context/useAuth'; import apiClient from '../utils/apiClient'; +import { useTranslation } from '../i18n'; interface QRLoginProps { onSuccess?: () => void; @@ -11,17 +12,22 @@ interface QRLoginProps { const QRLogin: React.FC = ({ onSuccess }) => { const navigate = useNavigate(); const { login } = useAuth(); + const { locale } = useTranslation(); + const isEn = locale === 'en'; const [qrCode, setQrCode] = useState(''); const [status, setStatus] = useState<'generating' | 'waiting' | 'scanning' | 'expired' | 'error'>('generating'); const [pollingInterval, setPollingInterval] = useState | null>(null); - const [refreshInterval, setRefreshInterval] = useState | null>(null); + const [countdownInterval, setCountdownInterval] = useState | null>(null); + + const [remaining, setRemaining] = useState(0); + const [requestInfo, setRequestInfo] = useState<{ ip?: string; ua?: string } | null>(null); const qrLinkBase = typeof window !== 'undefined' ? window.location.origin : ''; useEffect(() => { generateQR(); return () => { if (pollingInterval) clearInterval(pollingInterval); - if (refreshInterval) clearInterval(refreshInterval); + if (countdownInterval) clearInterval(countdownInterval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -33,13 +39,30 @@ const QRLogin: React.FC = ({ onSuccess }) => { setQrCode(response.data.code); setStatus('waiting'); startPolling(response.data.code); - - // Автоматическое обновление QR-кода каждые 60 секунд - if (refreshInterval) clearInterval(refreshInterval); - const interval = setInterval(() => { - generateQR(); - }, 60000); - setRefreshInterval(interval); + + // Устанавливаем время истечения и запускаем секундный таймер + const expires = response.data.expiresAt ? new Date(response.data.expiresAt).getTime() : (Date.now() + (response.data.expiresIn || 180) * 1000); + const initialRemaining = Math.max(0, Math.ceil((expires - Date.now()) / 1000)); + setRemaining(initialRemaining); + + if (countdownInterval) clearInterval(countdownInterval); + const cd = setInterval(() => { + setRemaining((prev) => { + if (prev <= 1) { + clearInterval(cd); + // expire QR + setStatus('expired'); + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + return 0; + } + return prev - 1; + }); + }, 1000); + setCountdownInterval(cd); + } catch (error) { console.error('Ошибка генерации QR:', error); setStatus('error'); @@ -51,6 +74,16 @@ const QRLogin: React.FC = ({ onSuccess }) => { try { const response = await apiClient.get(`/api/qr-auth/status/${code}`); + // Update remaining if server provides it + if (typeof response.data.expiresIn === 'number') { + setRemaining(Math.max(0, Math.ceil(response.data.expiresIn))); + } + + // Update request info if provided + if (response.data.ipAddress || response.data.userAgent) { + setRequestInfo({ ip: response.data.ipAddress, ua: response.data.userAgent }); + } + // Если статус изменился на "scanning" (пользователь открыл страницу подтверждения) if (response.data.status === 'scanning') { setStatus('scanning'); @@ -59,6 +92,7 @@ const QRLogin: React.FC = ({ onSuccess }) => { if (response.data.status === 'confirmed' && response.data.token) { clearInterval(interval); setPollingInterval(null); + if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); } // Вызываем login из контекста для обновления состояния login(response.data.token); @@ -71,6 +105,7 @@ const QRLogin: React.FC = ({ onSuccess }) => { } else if (response.data.status === 'rejected') { clearInterval(interval); setPollingInterval(null); + if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); } setStatus('error'); } } catch (error) { @@ -78,7 +113,15 @@ const QRLogin: React.FC = ({ onSuccess }) => { if (axiosError.response?.status === 404 || axiosError.response?.status === 410) { clearInterval(interval); setPollingInterval(null); + if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); } setStatus('expired'); + } else { + // Для прочих ошибок (500 и т.д.) прекращаем пуллинг и показываем ошибку + clearInterval(interval); + setPollingInterval(null); + if (countdownInterval) { clearInterval(countdownInterval); setCountdownInterval(null); } + console.error('Ошибка при проверке статуса QR:', error); + setStatus('error'); } } }, 2000); // Проверка каждые 2 секунды @@ -89,15 +132,15 @@ const QRLogin: React.FC = ({ onSuccess }) => { const getStatusMessage = () => { switch (status) { case 'generating': - return 'Генерация...'; + return isEn ? 'Generating...' : 'Генерация...'; case 'waiting': - return 'Отсканируйте QR-код телефоном, на котором вы уже авторизованы'; + return isEn ? 'Scan the QR code with your phone where you are already logged in' : 'Отсканируйте QR-код телефоном, на котором вы уже авторизованы'; case 'scanning': - return 'Ожидание подтверждения на телефоне...'; + return isEn ? 'Waiting for confirmation on phone...' : 'Ожидание подтверждения на телефоне...'; case 'expired': - return 'QR-код истёк'; + return isEn ? 'QR code expired' : 'QR-код истёк'; case 'error': - return 'Ошибка'; + return isEn ? 'Error' : 'Ошибка'; default: return ''; } @@ -106,7 +149,7 @@ const QRLogin: React.FC = ({ onSuccess }) => { return (
-

Вход по QR-коду

+

{isEn ? 'QR Code Login' : 'Вход по QR-коду'}

{getStatusMessage()}

@@ -129,6 +172,23 @@ const QRLogin: React.FC = ({ onSuccess }) => { includeMargin={true} />
+ + {/* Countdown */} +
+ {remaining > 0 ? ( +
+
Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, '0')}
+ {requestInfo && ( +
+
Device: {requestInfo.ua ?? '—'}
+
IP: {requestInfo.ip ?? '—'}
+
+ )} +
+ ) : ( + + )} +
)} @@ -139,7 +199,7 @@ const QRLogin: React.FC = ({ onSuccess }) => { onClick={generateQR} className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200" > - Обновить + {isEn ? 'Refresh' : 'Обновить'}
)} @@ -151,7 +211,7 @@ const QRLogin: React.FC = ({ onSuccess }) => { onClick={generateQR} className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200" > - Попробовать снова + {isEn ? 'Try again' : 'Попробовать снова'}
)} @@ -163,7 +223,7 @@ const QRLogin: React.FC = ({ onSuccess }) => { onClick={() => window.location.reload()} className="text-blue-500 hover:text-blue-600 font-medium text-sm" > - Войти по паролю + {isEn ? 'Login with password' : 'Войти по паролю'} diff --git a/ospabhost/frontend/src/components/ServerMetrics.tsx b/ospabhost/frontend/src/components/ServerMetrics.tsx index 213df5a..44ac51a 100644 --- a/ospabhost/frontend/src/components/ServerMetrics.tsx +++ b/ospabhost/frontend/src/components/ServerMetrics.tsx @@ -13,6 +13,7 @@ import { } from 'recharts'; import axios from 'axios'; import { API_URL } from '../config/api'; +import { useTranslation } from '../i18n'; interface ServerMetricsProps { serverId: number; @@ -58,6 +59,8 @@ interface Summary { } export default function ServerMetrics({ serverId }: ServerMetricsProps) { + const { locale } = useTranslation(); + const isEn = locale === 'en'; const [period, setPeriod] = useState<'1h' | '6h' | '24h' | '7d' | '30d'>('24h'); const [history, setHistory] = useState([]); const [current, setCurrent] = useState(null); @@ -78,19 +81,26 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); - if (days > 0) return `${days}д ${hours}ч`; - if (hours > 0) return `${hours}ч ${minutes}м`; - return `${minutes}м`; + if (isEn) { + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + } else { + if (days > 0) return `${days}д ${hours}ч`; + if (hours > 0) return `${hours}ч ${minutes}м`; + return `${minutes}м`; + } }; const formatTimestamp = (timestamp: string) => { const date = new Date(timestamp); + const localeStr = isEn ? 'en-US' : 'ru-RU'; if (period === '1h' || period === '6h') { - return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + return date.toLocaleTimeString(localeStr, { hour: '2-digit', minute: '2-digit' }); } else if (period === '24h') { - return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + return date.toLocaleTimeString(localeStr, { hour: '2-digit', minute: '2-digit' }); } else { - return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric' }); + return date.toLocaleDateString(localeStr, { month: 'short', day: 'numeric' }); } }; @@ -101,7 +111,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { const token = localStorage.getItem('access_token'); if (!token) { - throw new Error('Токен не найден. Пожалуйста, войдите снова.'); + throw new Error(isEn ? 'Token not found. Please log in again.' : 'Токен не найден. Пожалуйста, войдите снова.'); } // Получаем текущие метрики @@ -130,11 +140,11 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { const error = err as { response?: { status?: number; data?: { error?: string } }; message?: string }; console.error('❌ Ошибка загрузки метрик:', error); if (error.response?.status === 401) { - setError('Ошибка авторизации. Пожалуйста, войдите снова.'); + setError(isEn ? 'Authorization error. Please log in again.' : 'Ошибка авторизации. Пожалуйста, войдите снова.'); // Можно добавить редирект на логин // window.location.href = '/login'; } else { - setError(error.response?.data?.error || error.message || 'Ошибка загрузки метрик'); + setError(error.response?.data?.error || error.message || (isEn ? 'Error loading metrics' : 'Ошибка загрузки метрик')); } } finally { setLoading(false); @@ -152,7 +162,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { if (loading && !current) { return (
-
Загрузка метрик...
+
{isEn ? 'Loading metrics...' : 'Загрузка метрик...'}
); } @@ -165,7 +175,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { onClick={fetchMetrics} className="mt-2 text-sm text-red-600 hover:text-red-800 underline" > - Попробовать снова + {isEn ? 'Try again' : 'Попробовать снова'} ); @@ -193,7 +203,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {summary && (
- Ср: {summary.cpu.avg.toFixed(1)}% | Макс: {summary.cpu.max.toFixed(1)}% + {isEn ? 'Avg' : 'Ср'}: {summary.cpu.avg.toFixed(1)}% | {isEn ? 'Max' : 'Макс'}: {summary.cpu.max.toFixed(1)}%
)} @@ -201,7 +211,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {/* Memory */}
-

Память

+

{isEn ? 'Memory' : 'Память'}

{current.memory.usage.toFixed(1)}% @@ -211,7 +221,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
{summary && (
- Ср: {summary.memory.avg.toFixed(1)}% + {isEn ? 'Avg' : 'Ср'}: {summary.memory.avg.toFixed(1)}%
)}
@@ -219,7 +229,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {/* Disk */}
-

Диск

+

{isEn ? 'Disk' : 'Диск'}

{current.disk.usage.toFixed(1)}% @@ -229,7 +239,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
{summary && (
- Ср: {summary.disk.avg.toFixed(1)}% + {isEn ? 'Avg' : 'Ср'}: {summary.disk.avg.toFixed(1)}%
)}
@@ -237,7 +247,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {/* Network */}
-

Сеть

+

{isEn ? 'Network' : 'Сеть'}

↓ {formatBytes(current.network.in)} @@ -254,7 +264,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {/* Фильтр периода */}
- Период: + {isEn ? 'Period:' : 'Период:'} {(['1h', '6h', '24h', '7d', '30d'] as const).map((p) => ( ))}
@@ -275,7 +285,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
{/* CPU График */}
-

Использование CPU

+

{isEn ? 'CPU Usage' : 'Использование CPU'}

@@ -306,7 +316,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {/* Memory и Disk */}
-

Использование памяти и диска

+

{isEn ? 'Memory and Disk Usage' : 'Использование памяти и диска'}

@@ -329,14 +339,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { type="monotone" dataKey="memoryUsage" stroke="#3B82F6" - name="Память" + name={isEn ? 'Memory' : 'Память'} dot={false} /> @@ -345,7 +355,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { {/* Network Traffic */}
-

Сетевой трафик

+

{isEn ? 'Network Traffic' : 'Сетевой трафик'}

@@ -368,14 +378,14 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { dataKey="networkIn" stroke="#8B5CF6" fill="#C4B5FD" - name="Входящий" + name={isEn ? 'Incoming' : 'Входящий'} /> @@ -385,18 +395,18 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) {
📊

- {loading ? 'Загрузка данных...' : 'Нет данных за выбранный период'} + {loading ? (isEn ? 'Loading data...' : 'Загрузка данных...') : (isEn ? 'No data for selected period' : 'Нет данных за выбранный период')}

- {current ? 'Метрики собираются автоматически каждую минуту' : 'Данные появятся через 1-2 минуты после запуска сервера'} + {current ? (isEn ? 'Metrics are collected automatically every minute' : 'Метрики собираются автоматически каждую минуту') : (isEn ? 'Data will appear 1-2 minutes after server start' : 'Данные появятся через 1-2 минуты после запуска сервера')}

{current && (
-

💡 Хотите увидеть графики?

+

{isEn ? '💡 Want to see charts?' : '💡 Хотите увидеть графики?'}

- 1. Откройте консоль сервера
- 2. Запустите: stress-ng --cpu 2 --cpu-load 50 --timeout 180s
- 3. Обновите страницу через 1-2 минуты + {isEn ? '1. Open server console' : '1. Откройте консоль сервера'}
+ {isEn ? '2. Run: ' : '2. Запустите: '}stress-ng --cpu 2 --cpu-load 50 --timeout 180s
+ {isEn ? '3. Refresh page in 1-2 minutes' : '3. Обновите страницу через 1-2 минуты'}

)} @@ -404,7 +414,7 @@ export default function ServerMetrics({ serverId }: ServerMetricsProps) { onClick={fetchMetrics} className="mt-6 px-6 py-2 bg-ospab-primary text-white rounded-lg hover:bg-ospab-accent transition" > - 🔄 Обновить данные + {isEn ? '🔄 Refresh data' : '🔄 Обновить данные'}
)} diff --git a/ospabhost/frontend/src/components/Toast.tsx b/ospabhost/frontend/src/components/Toast.tsx index bc2979e..a7fd61c 100644 --- a/ospabhost/frontend/src/components/Toast.tsx +++ b/ospabhost/frontend/src/components/Toast.tsx @@ -113,7 +113,7 @@ const ToastItem: React.FC = ({ toast, onClose, index }) => { animation: `toast-enter 0.3s ease-out ${index * 0.1}s both` }} > -
+
{styles.icon}
diff --git a/ospabhost/frontend/src/components/footer.tsx b/ospabhost/frontend/src/components/footer.tsx index f1842c9..50b98e5 100644 --- a/ospabhost/frontend/src/components/footer.tsx +++ b/ospabhost/frontend/src/components/footer.tsx @@ -1,9 +1,13 @@ import { Link } from 'react-router-dom'; import { FaGithub } from 'react-icons/fa'; import logo from '../assets/logo.svg'; +import { useTranslation } from '../i18n'; +import { useLocalePath } from '../middleware'; const Footer = () => { const currentYear = new Date().getFullYear(); + const { t, locale, setLocale } = useTranslation(); + const localePath = useLocalePath(); return (
@@ -12,32 +16,32 @@ const Footer = () => { {/* About Section */}
- Логотип + Logo
-

О нас

+

{t('nav.about')}

- ospab.host - это надежный хостинг для ваших проектов. Мы предлагаем высокую производительность и круглосуточную поддержку. + {t('footer.description')}

{/* Quick Links */}
-

Навигация

+

{t('footer.links')}

    -
  • Главная
  • -
  • Тарифы
  • -
  • О нас
  • -
  • Блог
  • -
  • Войти
  • +
  • {t('nav.home')}
  • +
  • {t('nav.tariffs')}
  • +
  • {t('nav.about')}
  • +
  • {t('nav.blog')}
  • +
  • {t('nav.login')}
{/* Legal Documents */}
-

Документы

+

{t('footer.legal')}

    -
  • Политика конфиденциальности
  • -
  • Условия использования
  • +
  • {t('footer.privacy')}
  • +
  • {t('footer.terms')}
  • {
-
+

- © {currentYear} ospab.host. Все права защищены. + © {currentYear} ospab.host. {locale === 'en' ? 'All rights reserved.' : 'Все права защищены.'}

+ + {/* Language Switcher */} +
+ + +
diff --git a/ospabhost/frontend/src/components/header.tsx b/ospabhost/frontend/src/components/header.tsx index 52403ab..555c46f 100644 --- a/ospabhost/frontend/src/components/header.tsx +++ b/ospabhost/frontend/src/components/header.tsx @@ -3,10 +3,14 @@ import { useState } from 'react'; import useAuth from '../context/useAuth'; import logo from '../assets/logo.svg'; import NotificationBell from './NotificationBell'; +import { useTranslation } from '../i18n'; +import { useLocalePath } from '../middleware'; const Header = () => { const { isLoggedIn, logout } = useAuth(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { t } = useTranslation(); + const localePath = useLocalePath(); const handleLogout = () => { logout(); @@ -18,36 +22,36 @@ const Header = () => {
- - Логотип + + Logo ospab.host
{/* Desktop Menu */}
- Тарифы - Блог - О нас + {t('nav.tariffs')} + {t('nav.blog')} + {t('nav.about')} {isLoggedIn ? ( <> - Личный кабинет + {t('nav.dashboard')} ) : ( <> - Войти + {t('nav.login')} - Зарегистрироваться + {t('nav.register')} )} @@ -57,7 +61,7 @@ const Header = () => { ) : ( <> setIsMobileMenuOpen(false)} > - Войти + {t('nav.login')} setIsMobileMenuOpen(false)} > - Зарегистрироваться + {t('nav.register')} )} diff --git a/ospabhost/frontend/src/i18n/index.ts b/ospabhost/frontend/src/i18n/index.ts new file mode 100644 index 0000000..25a60df --- /dev/null +++ b/ospabhost/frontend/src/i18n/index.ts @@ -0,0 +1,3 @@ +export { useTranslation, getTranslation } from './useTranslation'; +export type { TranslationKey, TranslationKeys } from './useTranslation'; +export { ru, en } from './translations'; diff --git a/ospabhost/frontend/src/i18n/translations/en.ts b/ospabhost/frontend/src/i18n/translations/en.ts new file mode 100644 index 0000000..cad78dd --- /dev/null +++ b/ospabhost/frontend/src/i18n/translations/en.ts @@ -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.', + }, +}; diff --git a/ospabhost/frontend/src/i18n/translations/index.ts b/ospabhost/frontend/src/i18n/translations/index.ts new file mode 100644 index 0000000..18b8c89 --- /dev/null +++ b/ospabhost/frontend/src/i18n/translations/index.ts @@ -0,0 +1,2 @@ +export { ru, type TranslationKeys } from './ru'; +export { en } from './en'; diff --git a/ospabhost/frontend/src/i18n/translations/ru.ts b/ospabhost/frontend/src/i18n/translations/ru.ts new file mode 100644 index 0000000..a52ee7f --- /dev/null +++ b/ospabhost/frontend/src/i18n/translations/ru.ts @@ -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; diff --git a/ospabhost/frontend/src/i18n/useTranslation.ts b/ospabhost/frontend/src/i18n/useTranslation.ts new file mode 100644 index 0000000..2b9b7ad --- /dev/null +++ b/ospabhost/frontend/src/i18n/useTranslation.ts @@ -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 = { + ru, + en, +}; + +type NestedKeyOf = T extends object + ? { + [K in keyof T]: K extends string + ? T[K] extends object + ? `${K}` | `${K}.${NestedKeyOf}` + : `${K}` + : never; + }[keyof T] + : never; + +type TranslationKey = NestedKeyOf; + +/** + * Получить значение по вложенному ключу + */ +function getNestedValue(obj: Record, 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)[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 => { + const translation = getNestedValue( + translations[locale] as unknown as Record, + key + ); + + if (!params) return translation; + + // Замена параметров {{param}} + return translation.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => { + return params[paramKey]?.toString() ?? `{{${paramKey}}}`; + }); + }, + [locale] + ); + + // Получить объект переводов для секции + const tSection = useCallback( + (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, key); +} + +export type { TranslationKey, TranslationKeys }; diff --git a/ospabhost/frontend/src/index.css b/ospabhost/frontend/src/index.css index 90bdb2b..898231d 100644 --- a/ospabhost/frontend/src/index.css +++ b/ospabhost/frontend/src/index.css @@ -2,6 +2,244 @@ @tailwind components; @tailwind utilities; +/* ===== МОБИЛЬНАЯ АДАПТАЦИЯ ===== */ + +/* Улучшенный перенос слов для мобильных устройств */ +@media (max-width: 768px) { + .prose { + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + } + + .prose p, .prose li { + word-wrap: break-word; + overflow-wrap: break-word; + } + + /* Предотвращение горизонтальной прокрутки */ + body { + overflow-x: hidden; + } + + /* Адаптация длинных слов в интерфейсе */ + .break-word-mobile { + word-break: break-word; + hyphens: auto; + } + + /* Усечение текста с троеточием на мобильных */ + .truncate-mobile { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* Line clamp utilities для совместимости */ +.line-clamp-1 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.line-clamp-3 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +/* ===== ГЛОБАЛЬНЫЕ АНИМАЦИИ ===== */ + +/* Fade in снизу */ +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Fade in сверху */ +@keyframes fade-in-down { + from { + opacity: 0; + transform: translateY(-30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Fade in слева */ +@keyframes fade-in-left { + from { + opacity: 0; + transform: translateX(-30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Fade in справа */ +@keyframes fade-in-right { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Scale in */ +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Bounce subtle */ +@keyframes bounce-subtle { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } +} + +/* Float */ +@keyframes float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +/* Pulse glow */ +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 5px rgba(59, 130, 246, 0.5); + } + 50% { + box-shadow: 0 0 20px rgba(59, 130, 246, 0.8); + } +} + +/* Slide in from bottom for cards */ +@keyframes slide-up { + from { + opacity: 0; + transform: translateY(50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Классы анимаций */ +.animate-fade-in-up { + animation: fade-in-up 0.6s ease-out forwards; +} + +.animate-fade-in-down { + animation: fade-in-down 0.6s ease-out forwards; +} + +.animate-fade-in-left { + animation: fade-in-left 0.6s ease-out forwards; +} + +.animate-fade-in-right { + animation: fade-in-right 0.6s ease-out forwards; +} + +.animate-scale-in { + animation: scale-in 0.5s ease-out forwards; +} + +.animate-bounce-subtle { + animation: bounce-subtle 2s ease-in-out infinite; +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; +} + +.animate-slide-up { + animation: slide-up 0.6s ease-out forwards; +} + +/* Задержки анимаций */ +.animation-delay-100 { animation-delay: 0.1s; } +.animation-delay-200 { animation-delay: 0.2s; } +.animation-delay-300 { animation-delay: 0.3s; } +.animation-delay-400 { animation-delay: 0.4s; } +.animation-delay-500 { animation-delay: 0.5s; } +.animation-delay-600 { animation-delay: 0.6s; } +.animation-delay-700 { animation-delay: 0.7s; } +.animation-delay-800 { animation-delay: 0.8s; } + +/* Начальное состояние для анимируемых элементов */ +.animate-on-scroll { + opacity: 0; +} + +/* Hover эффекты для карточек */ +.card-hover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.card-hover:hover { + transform: translateY(-8px); + box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.2); +} + +/* Hover эффект для кнопок */ +.btn-hover { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.btn-hover:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.3); +} + +.btn-hover:active { + transform: translateY(0); +} + +/* ===== СУЩЕСТВУЮЩИЕ АНИМАЦИИ ===== */ + /* Анимации для модальных окон и уведомлений */ @keyframes modal-enter { from { diff --git a/ospabhost/frontend/src/pages/401.tsx b/ospabhost/frontend/src/pages/401.tsx index 707da74..1db5891 100644 --- a/ospabhost/frontend/src/pages/401.tsx +++ b/ospabhost/frontend/src/pages/401.tsx @@ -1,11 +1,13 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function Unauthorized() { + const { t } = useTranslation(); return ( diff --git a/ospabhost/frontend/src/pages/403.tsx b/ospabhost/frontend/src/pages/403.tsx index f00ddb4..00f0551 100644 --- a/ospabhost/frontend/src/pages/403.tsx +++ b/ospabhost/frontend/src/pages/403.tsx @@ -1,11 +1,13 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function Forbidden() { + const { t } = useTranslation(); return ( diff --git a/ospabhost/frontend/src/pages/404.tsx b/ospabhost/frontend/src/pages/404.tsx index c4eb53e..641d9d3 100644 --- a/ospabhost/frontend/src/pages/404.tsx +++ b/ospabhost/frontend/src/pages/404.tsx @@ -1,11 +1,14 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function NotFound() { + const { t } = useTranslation(); + return ( diff --git a/ospabhost/frontend/src/pages/500.tsx b/ospabhost/frontend/src/pages/500.tsx index 438c0b4..e16bc3b 100644 --- a/ospabhost/frontend/src/pages/500.tsx +++ b/ospabhost/frontend/src/pages/500.tsx @@ -1,11 +1,13 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function ServerError() { + const { t } = useTranslation(); return ( diff --git a/ospabhost/frontend/src/pages/502.tsx b/ospabhost/frontend/src/pages/502.tsx index 493c5d0..18bccb0 100644 --- a/ospabhost/frontend/src/pages/502.tsx +++ b/ospabhost/frontend/src/pages/502.tsx @@ -1,11 +1,13 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function BadGateway() { + const { t } = useTranslation(); return ( diff --git a/ospabhost/frontend/src/pages/503.tsx b/ospabhost/frontend/src/pages/503.tsx index 34bc16f..9d63e19 100644 --- a/ospabhost/frontend/src/pages/503.tsx +++ b/ospabhost/frontend/src/pages/503.tsx @@ -1,11 +1,13 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function ServiceUnavailable() { + const { t } = useTranslation(); return ( diff --git a/ospabhost/frontend/src/pages/504.tsx b/ospabhost/frontend/src/pages/504.tsx index 96c6157..1ddb7b5 100644 --- a/ospabhost/frontend/src/pages/504.tsx +++ b/ospabhost/frontend/src/pages/504.tsx @@ -1,11 +1,13 @@ import ErrorPage from '../components/ErrorPage'; +import { useTranslation } from '../i18n'; export default function GatewayTimeout() { + const { t } = useTranslation(); return ( diff --git a/ospabhost/frontend/src/pages/about.tsx b/ospabhost/frontend/src/pages/about.tsx index 20bf3f9..9a9731c 100644 --- a/ospabhost/frontend/src/pages/about.tsx +++ b/ospabhost/frontend/src/pages/about.tsx @@ -1,6 +1,12 @@ import { FaRocket, FaUsers, FaShieldAlt, FaChartLine, FaHeart, FaServer, FaGithub } from 'react-icons/fa'; +import { useTranslation } from '../i18n'; +import { useLocalePath } from '../middleware'; const AboutPage = () => { + const { locale } = useTranslation(); + const localePath = useLocalePath(); + const isEn = locale === 'en'; + return (
{/* Hero Section */} @@ -13,10 +19,10 @@ const AboutPage = () => {

- История ospab.host + {isEn ? 'The Story of ospab.host' : 'История ospab.host'}

- Первый дата-центр в Великом Новгороде. + {isEn ? 'The first data center in Veliky Novgorod.' : 'Первый дата-центр в Великом Новгороде.'}

@@ -30,7 +36,7 @@ const AboutPage = () => {
Георгий, основатель ospab.host {
-

Георгий

-

Основатель и CEO

+

{isEn ? 'Georgy' : 'Георгий'}

+

{isEn ? 'Founder & CEO' : 'Основатель и CEO'}

- В 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 лет я решил создать то, чего не было в моём городе — современный дата-центр. Начав с изучения технологий и работы над первым хостингом, я постепенно превращаю мечту в реальность. С помощью друга-инвестора мы строим инфраструктуру будущего для Великого Новгорода.'}

@@ -78,42 +84,43 @@ const AboutPage = () => {

- Наша история + {isEn ? 'Our Story' : 'Наша история'}

- Сентябрь 2025 — Начало пути + {isEn ? 'September 2025 — The Beginning' : 'Сентябрь 2025 — Начало пути'}

- Всё началось с простой идеи: создать место, где любой сможет разместить свой проект, - сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает - свой дата-центр, и я решил взяться за эту задачу. + {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." + : 'Всё началось с простой идеи: создать место, где любой сможет разместить свой проект, сайт или сервер с максимальной надёжностью и скоростью. Великий Новгород заслуживает свой дата-центр, и я решил взяться за эту задачу.'}

- Поддержка и развитие + {isEn ? 'Support and Development' : 'Поддержка и развитие'}

- Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры. - Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг, - а поддержка всегда рядом. + {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." + : 'Мой друг-инвестор поверил в проект и помогает с развитием инфраструктуры. Мы строим не просто бизнес, а сообщество, где каждый клиент — как друг, а поддержка всегда рядом.'}

- Настоящее и будущее + {isEn ? 'Present and Future' : 'Настоящее и будущее'}

- Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД. - ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем. + {isEn + ? "Currently, we're actively working on hosting and preparing infrastructure for the future data center. ospab.host is the first step towards the digital future of Veliky Novgorod, and we're just getting started." + : 'Сейчас мы активно работаем над хостингом и подготовкой инфраструктуры для будущего ЦОД. ospab.host — это первый шаг к цифровому будущему Великого Новгорода, и мы только начинаем.'}

@@ -125,10 +132,12 @@ const AboutPage = () => {

- Наша миссия + {isEn ? 'Our Mission' : 'Наша миссия'}

- Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города + {isEn + ? "Make quality hosting accessible to everyone, and the data center — the city's pride" + : 'Сделать качественный хостинг доступным для всех, а ЦОД — гордостью города'}

@@ -137,9 +146,11 @@ const AboutPage = () => {
-

Современные технологии

+

{isEn ? 'Modern Technologies' : 'Современные технологии'}

- Используем новейшее оборудование и программное обеспечение для максимальной производительности + {isEn + ? 'We use the latest equipment and software for maximum performance' + : 'Используем новейшее оборудование и программное обеспечение для максимальной производительности'}

@@ -147,9 +158,11 @@ const AboutPage = () => {
-

Безопасность данных

+

{isEn ? 'Data Security' : 'Безопасность данных'}

- Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7 + {isEn + ? 'Customer data protection is our priority. Regular backups and 24/7 monitoring' + : 'Защита информации клиентов — наш приоритет. Регулярные бэкапы и мониторинг 24/7'}

@@ -157,9 +170,11 @@ const AboutPage = () => {
-

Личная поддержка

+

{isEn ? 'Personal Support' : 'Личная поддержка'}

- Каждый клиент получает персональное внимание и помощь от основателя + {isEn + ? 'Every customer receives personal attention and help from the founder' + : 'Каждый клиент получает персональное внимание и помощь от основателя'}

@@ -171,7 +186,7 @@ const AboutPage = () => {

- Почему выбирают ospab.host? + {isEn ? 'Why choose ospab.host?' : 'Почему выбирают ospab.host?'}

@@ -180,8 +195,8 @@ const AboutPage = () => {
-

Первый ЦОД в городе

-

Мы создаём историю Великого Новгорода

+

{isEn ? 'First data center in the city' : 'Первый ЦОД в городе'}

+

{isEn ? "We're making Veliky Novgorod history" : 'Мы создаём историю Великого Новгорода'}

@@ -190,8 +205,8 @@ const AboutPage = () => {
-

Доступные тарифы

-

Качественный хостинг для всех без переплат

+

{isEn ? 'Affordable pricing' : 'Доступные тарифы'}

+

{isEn ? 'Quality hosting for everyone without overpaying' : 'Качественный хостинг для всех без переплат'}

@@ -200,8 +215,8 @@ const AboutPage = () => {
-

Быстрая поддержка

-

Ответим на вопросы в любое время

+

{isEn ? 'Fast support' : 'Быстрая поддержка'}

+

{isEn ? "We'll answer questions anytime" : 'Ответим на вопросы в любое время'}

@@ -210,8 +225,8 @@ const AboutPage = () => {
-

Прозрачность

-

Честно о возможностях и ограничениях

+

{isEn ? 'Transparency' : 'Прозрачность'}

+

{isEn ? 'Honest about capabilities and limitations' : 'Честно о возможностях и ограничениях'}

@@ -220,8 +235,8 @@ const AboutPage = () => {
-

Современная инфраструктура

-

Актуальное ПО и оборудование

+

{isEn ? 'Modern infrastructure' : 'Современная инфраструктура'}

+

{isEn ? 'Up-to-date software and equipment' : 'Актуальное ПО и оборудование'}

@@ -230,8 +245,8 @@ const AboutPage = () => {
-

Мечта становится реальностью

-

История, которой можно гордиться

+

{isEn ? 'A dream becoming reality' : 'Мечта становится реальностью'}

+

{isEn ? 'A story to be proud of' : 'История, которой можно гордиться'}

@@ -248,7 +263,7 @@ const AboutPage = () => { rel="noopener noreferrer" className="hover:underline" > - Исходный код на GitHub + {isEn ? 'Source code on GitHub' : 'Исходный код на GitHub'}

@@ -262,23 +277,25 @@ const AboutPage = () => {
diff --git a/ospabhost/frontend/src/pages/blog.tsx b/ospabhost/frontend/src/pages/blog.tsx index 86c6bad..376029f 100644 --- a/ospabhost/frontend/src/pages/blog.tsx +++ b/ospabhost/frontend/src/pages/blog.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import axios from 'axios'; import { API_URL } from '../config/api'; +import { useTranslation } from '../i18n'; +import { useLocalePath } from '../middleware'; interface Post { id: number; @@ -25,6 +27,8 @@ const Blog: React.FC = () => { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { t, locale } = useTranslation(); + const localePath = useLocalePath(); useEffect(() => { loadPosts(); @@ -37,8 +41,8 @@ const Blog: React.FC = () => { setPosts(response.data.data); setError(null); } catch (err) { - console.error('Ошибка загрузки постов:', err); - setError('Не удалось загрузить статьи'); + console.error('Error loading posts:', err); + setError(locale === 'en' ? 'Failed to load articles' : 'Не удалось загрузить статьи'); } finally { setLoading(false); } @@ -46,7 +50,7 @@ const Blog: React.FC = () => { const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleDateString('ru-RU', { + return date.toLocaleDateString(locale === 'en' ? 'en-US' : 'ru-RU', { year: 'numeric', month: 'long', day: 'numeric' @@ -56,7 +60,7 @@ const Blog: React.FC = () => { if (loading) { return (
-
Загрузка...
+
{t('common.loading')}
); } @@ -66,9 +70,9 @@ const Blog: React.FC = () => {
{/* Header */}
-

Блог

+

{t('blog.title')}

- Новости, статьи и полезные материалы о хостинге + {t('blog.subtitle')}

@@ -82,14 +86,14 @@ const Blog: React.FC = () => { {/* Posts Grid */} {posts.length === 0 ? (
-

📭 Статей пока нет

+

📭 {t('blog.noPosts')}

) : (
{posts.map((post) => ( {/* Cover Image */} @@ -103,7 +107,7 @@ const Blog: React.FC = () => {
) : (
- Статья + {locale === 'en' ? 'Article' : 'Статья'}
)} @@ -122,9 +126,8 @@ const Blog: React.FC = () => { {/* Meta */}
- Автор: {post.author.username} - Просмотров: {post.views} - Комментариев: {post._count.comments} + {t('blog.author')}: {post.author.username} + {locale === 'en' ? 'Views' : 'Просмотров'}: {post.views}
diff --git a/ospabhost/frontend/src/pages/blogpost.tsx b/ospabhost/frontend/src/pages/blogpost.tsx index 0f369a6..9a6d22e 100644 --- a/ospabhost/frontend/src/pages/blogpost.tsx +++ b/ospabhost/frontend/src/pages/blogpost.tsx @@ -185,7 +185,7 @@ const BlogPost: React.FC = () => {
Автор: - {post.author.username} + {post.author.username}
Дата: @@ -257,7 +257,7 @@ const BlogPost: React.FC = () => { {post.comments.map((comment) => (
- + {comment.user ? comment.user.username : comment.authorName} diff --git a/ospabhost/frontend/src/pages/dashboard/admin.tsx b/ospabhost/frontend/src/pages/dashboard/admin.tsx index 433a4e8..e819df5 100644 --- a/ospabhost/frontend/src/pages/dashboard/admin.tsx +++ b/ospabhost/frontend/src/pages/dashboard/admin.tsx @@ -769,93 +769,90 @@ const AdminPanel = () => {

{usersError}

) : ( -
- - - - - - - - - - - - - - {filteredUsers.length === 0 ? ( + <> + {/* Mobile: stacked cards */} +
+ {filteredUsers.length === 0 ? ( +
Пользователи не найдены.
+ ) : ( + filteredUsers.map((user) => ( +
+
+
+
{user.username}
+
ID {user.id} · {formatDateTime(user.createdAt)}
+
{user.email}
+
+
+
= 0 ? 'text-gray-900' : 'text-red-600'}`}>{formatCurrency(user.balance)}
+
{formatNumber(user._count.buckets ?? 0)} бакетов · {formatNumber(user._count.tickets ?? 0)} тикетов
+
+
+
+ + + + +
+
+ )) + )} +
+ + {/* Desktop table */} +
+
ПользовательEmailБалансСервераТикетыРолиДействия
+ - + + + + + + + - ) : ( - filteredUsers.map((user) => { - const busy = roleUpdating[user.id] || deletingUserId === user.id; - return ( - - - - - - - - - - ); - }) - )} - -
- Пользователи не найдены. - ПользовательEmailБалансСервераТикетыРолиДействия
-
{user.username}
-
ID {user.id} · {formatDateTime(user.createdAt)}
-
{user.email}= 0 ? 'text-gray-900' : 'text-red-600' - }`} - > - {formatCurrency(user.balance)} - {formatNumber(user._count.buckets ?? 0)}{formatNumber(user._count.tickets ?? 0)} -
- - Админ - - - Оператор - -
-
-
- - - - -
-
-
+ + + {filteredUsers.length === 0 ? ( + + Пользователи не найдены. + + ) : ( + filteredUsers.map((user) => { + const busy = roleUpdating[user.id] || deletingUserId === user.id; + return ( + + +
{user.username}
+
ID {user.id} · {formatDateTime(user.createdAt)}
+ + {user.email} + = 0 ? 'text-gray-900' : 'text-red-600'}`}>{formatCurrency(user.balance)} + {formatNumber(user._count.buckets ?? 0)} + {formatNumber(user._count.tickets ?? 0)} + +
+ Админ + Оператор +
+ + +
+ + + + +
+ + + ); + }) + )} + + +
+ )}
)} diff --git a/ospabhost/frontend/src/pages/dashboard/billing.tsx b/ospabhost/frontend/src/pages/dashboard/billing.tsx index f765e60..d11046c 100644 --- a/ospabhost/frontend/src/pages/dashboard/billing.tsx +++ b/ospabhost/frontend/src/pages/dashboard/billing.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import apiClient from '../../utils/apiClient'; import QRCode from 'react-qr-code'; import { API_URL } from '../../config/api'; +import { useTranslation } from '../../i18n'; const sbpUrl = import.meta.env.VITE_SBP_QR_URL; const cardNumber = import.meta.env.VITE_CARD_NUMBER; @@ -15,6 +16,9 @@ interface Check { } const Billing = () => { + const { locale } = useTranslation(); + const isEn = locale === 'en'; + const [amount, setAmount] = useState(0); const [balance, setBalance] = useState(0); const [checks, setChecks] = useState([]); @@ -136,11 +140,11 @@ const Billing = () => { const getStatusText = (status: string) => { switch (status) { case 'approved': - return 'Зачислено'; + return isEn ? 'Approved' : 'Зачислено'; case 'rejected': - return 'Отклонено'; + return isEn ? 'Rejected' : 'Отклонено'; default: - return 'На проверке'; + return isEn ? 'Pending' : 'На проверке'; } }; @@ -157,7 +161,9 @@ const Billing = () => { return (
-

Пополнение баланса

+

+ {isEn ? 'Top Up Balance' : 'Пополнение баланса'} +

{/* Сообщение */} {message && ( @@ -172,7 +178,7 @@ const Billing = () => { {/* Текущий баланс */}
-

Текущий баланс

+

{isEn ? 'Current Balance' : 'Текущий баланс'}

{balance.toFixed(2)} ₽

@@ -181,7 +187,7 @@ const Billing = () => { {/* Ввод суммы */}
{ onChange={(e) => setAmount(Number(e.target.value))} className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-ospab-primary" min="1" - placeholder="Введите сумму" + placeholder={isEn ? 'Enter amount' : 'Введите сумму'} />
{/* Быстрые суммы */}
-

Быстрый выбор:

+

{isEn ? 'Quick select:' : 'Быстрый выбор:'}

{quickAmounts.map((quickAmount) => (
) : (
{/* Инструкция */}
-

Инструкция по оплате

+

{isEn ? 'Payment Instructions' : 'Инструкция по оплате'}

    -
  1. Переведите ₽{amount} по СБП или на карту
  2. -
  3. Сохраните чек об оплате
  4. -
  5. Загрузите чек ниже для проверки
  6. +
  7. {isEn ? <>Transfer ₽{amount} via SBP or to card : <>Переведите ₽{amount} по СБП или на карту}
  8. +
  9. {isEn ? 'Save the payment receipt' : 'Сохраните чек об оплате'}
  10. +
  11. {isEn ? 'Upload the receipt below for verification' : 'Загрузите чек ниже для проверки'}
@@ -238,18 +244,18 @@ const Billing = () => {
{/* QR СБП */}
-

Оплата по СБП

+

{isEn ? 'Pay via SBP' : 'Оплата по СБП'}

- Отсканируйте QR-код в приложении банка + {isEn ? 'Scan QR code in your bank app' : 'Отсканируйте QR-код в приложении банка'}

{/* Номер карты */}
-

Номер карты

+

{isEn ? 'Card Number' : 'Номер карты'}

{cardNumber || '0000 0000 0000 0000'}

@@ -257,35 +263,35 @@ const Billing = () => { onClick={handleCopyCard} className="w-full px-4 py-2 rounded-lg text-white font-semibold bg-gray-700 hover:bg-gray-800 transition" > - Скопировать номер карты + {isEn ? 'Copy card number' : 'Скопировать номер карты'}
{/* Загрузка чека */}
-

Загрузка чека

+

{isEn ? 'Upload Receipt' : 'Загрузка чека'}

{checkFile ? (

- Выбран файл: {checkFile.name} + {isEn ? 'Selected file:' : 'Выбран файл:'} {checkFile.name}

- Размер: {(checkFile.size / 1024 / 1024).toFixed(2)} МБ + {isEn ? 'Size:' : 'Размер:'} {(checkFile.size / 1024 / 1024).toFixed(2)} {isEn ? 'MB' : 'МБ'}

@@ -293,7 +299,7 @@ const Billing = () => {

-

JPG, PNG, PDF (до 10 МБ)

+

{isEn ? 'JPG, PNG, PDF (up to 10 MB)' : 'JPG, PNG, PDF (до 10 МБ)'}

)}
@@ -314,14 +320,14 @@ const Billing = () => { }} className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-xl hover:bg-gray-300 transition" > - Изменить сумму + {isEn ? 'Change amount' : 'Изменить сумму'}
)} {/* История чеков */}
-

История чеков

+

{isEn ? 'Receipt History' : 'История чеков'}

{checks.length > 0 ? (
{checks.map((check) => ( @@ -332,7 +338,7 @@ const Billing = () => {

{check.amount} ₽

- {new Date(check.createdAt).toLocaleString('ru-RU')} + {new Date(check.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')}

@@ -362,18 +368,18 @@ const Billing = () => { }) .catch(err => { console.error('Ошибка загрузки чека:', err); - showMessage('Не удалось загрузить чек', 'error'); + showMessage(isEn ? 'Failed to load receipt' : 'Не удалось загрузить чек', 'error'); }); }} > - Чек + {isEn ? 'Receipt' : 'Чек'}
))}
) : ( -

История чеков пуста

+

{isEn ? 'No receipts yet' : 'История чеков пуста'}

)}
diff --git a/ospabhost/frontend/src/pages/dashboard/checkout.tsx b/ospabhost/frontend/src/pages/dashboard/checkout.tsx index 18f92ae..4ce82dd 100644 --- a/ospabhost/frontend/src/pages/dashboard/checkout.tsx +++ b/ospabhost/frontend/src/pages/dashboard/checkout.tsx @@ -33,6 +33,8 @@ type CartPayload = { plan: CheckoutPlan; price: number; expiresAt: string; + originalPrice?: number | null; + promoDiscount?: number | null; }; const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/; @@ -187,14 +189,33 @@ const Checkout: React.FC = () => { const handleApplyPromo = useCallback(async () => { if (!cart) return; + if (!promoCode.trim()) { + setPromoError('Введите промокод'); + return; + } setPromoError(null); try { - const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode }); + const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode: promoCode.trim() }); const updated = res.data?.cart; if (updated) setCart(updated as CartPayload); setPromoApplied(true); - } catch (err) { - setPromoError(err instanceof Error ? err.message : 'Не удалось применить промокод'); + } catch (err: unknown) { + const axiosErr = err as { response?: { data?: { error?: string } }; message?: string }; + const raw = axiosErr.response?.data?.error || axiosErr.message || 'Не удалось применить промокод'; + + // Friendly localized mapping + const msg = String(raw || '').toLowerCase(); + if (msg.includes('неверный') || msg.includes('invalid')) { + setPromoError('Неверный промокод'); + } else if (msg.includes('уже использован') || msg.includes('already used')) { + setPromoError('Этот промокод уже использован'); + } else if (msg.includes('корзина не найдена') || msg.includes('cart')) { + setPromoError('Корзина не найдена или просрочена'); + } else if (msg.includes('promoCode модель недоступна') || msg.includes('promocode модель')) { + setPromoError('Серверная ошибка: PromoCode модель недоступна. Выполните prisma generate на сервере.'); + } else { + setPromoError(raw as string); + } } }, [cart, promoCode]); @@ -287,14 +308,66 @@ const Checkout: React.FC = () => {
)} -
- -
- setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" /> - + {/* Promo Code Section */} +
+
+ +

Промокод

- {promoError &&
{promoError}
} - {promoApplied && !promoError &&
Промокод применён
} +
+
+ { + 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 && ( +
+ + + +
+ )} +
+ +
+ {promoError && ( +
+ + {promoError} +
+ )} + {promoApplied && !promoError && ( +
+ + + + Промокод успешно применён! Скидка учтена в итоговой сумме. +
+ )}
@@ -481,12 +554,72 @@ const Checkout: React.FC = () => {
{plan && ( -

= 0 ? 'text-emerald-600' : 'text-red-600'}`}> - {balanceAfterPayment >= 0 - ? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}` - : `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`} -

+ <> +

= 0 ? 'text-emerald-600' : 'text-red-600'}`}> + {balanceAfterPayment >= 0 + ? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}` + : `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`} +

+ + {/* Discount row */} + {cart && typeof cart.originalPrice === 'number' && cart.originalPrice > (cart.price ?? 0) && ( +
+

Скидка

+

-{formatCurrency((cart.originalPrice ?? 0) - (cart.price ?? 0))}

+
+ )} + )} + + {/* Promo: moved here between total and button */} +
+ +
+ { + 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} + /> + +
+ {promoError && ( +
+ + {promoError} +
+ )} + {promoApplied && !promoError && ( +
+ + + + Промокод успешно применён! Скидка учтена в итоговой сумме. +
+ )} +
+ +
+

© 2025 ospab.host

-

Версия 1.0.0

+

{isEn ? 'Version' : 'Версия'} 1.0.0

@@ -241,10 +267,10 @@ const Dashboard = () => {

- {tabs.concat(adminTabs).find(t => t.key === activeTab)?.label || 'Панель управления'} + {tabs.concat(adminTabs).concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}

- {new Date().toLocaleDateString('ru-RU', { + {new Date().toLocaleDateString(isEn ? 'en-US' : 'ru-RU', { weekday: 'long', year: 'numeric', month: 'long', diff --git a/ospabhost/frontend/src/pages/dashboard/notifications.tsx b/ospabhost/frontend/src/pages/dashboard/notifications.tsx index 08e1948..f00efe9 100644 --- a/ospabhost/frontend/src/pages/dashboard/notifications.tsx +++ b/ospabhost/frontend/src/pages/dashboard/notifications.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; +import { useTranslation } from '../../i18n'; import { getNotifications, markAsRead, @@ -16,6 +17,8 @@ const NotificationsPage = () => { const [filter, setFilter] = useState<'all' | 'unread'>('all'); const [pushEnabled, setPushEnabled] = useState(false); const [pushPermission, setPushPermission] = useState('default'); + const { locale } = useTranslation(); + const isEn = locale === 'en'; const checkPushPermission = () => { if ('Notification' in window) { @@ -83,7 +86,7 @@ const NotificationsPage = () => { }; const handleDeleteAllRead = async () => { - if (!window.confirm('Удалить все прочитанные уведомления?')) return; + if (!window.confirm(isEn ? 'Delete all read notifications?' : 'Удалить все прочитанные уведомления?')) return; try { await deleteAllRead(); @@ -98,9 +101,9 @@ const NotificationsPage = () => { if (success) { setPushEnabled(true); setPushPermission('granted'); - alert('Push-уведомления успешно подключены!'); + alert(isEn ? 'Push notifications enabled!' : 'Push-уведомления успешно подключены!'); } else { - alert('Не удалось подключить Push-уведомления. Проверьте разрешения браузера.'); + alert(isEn ? 'Failed to enable push notifications. Check your browser permissions.' : 'Не удалось подключить Push-уведомления. Проверьте разрешения браузера.'); // Обновляем состояние на случай, если пользователь отклонил checkPushPermission(); } @@ -108,7 +111,7 @@ const NotificationsPage = () => { const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleString('ru-RU', { + return date.toLocaleString(isEn ? 'en-US' : 'ru-RU', { day: 'numeric', month: 'long', year: 'numeric', @@ -125,11 +128,16 @@ const NotificationsPage = () => { const weekAgo = new Date(today); weekAgo.setDate(weekAgo.getDate() - 7); + const todayLabel = isEn ? 'Today' : 'Сегодня'; + const yesterdayLabel = isEn ? 'Yesterday' : 'Вчера'; + const weekLabel = isEn ? 'Last 7 days' : 'За последние 7 дней'; + const earlierLabel = isEn ? 'Earlier' : 'Ранее'; + const groups: Record = { - 'Сегодня': [], - 'Вчера': [], - 'За последние 7 дней': [], - 'Ранее': [] + [todayLabel]: [], + [yesterdayLabel]: [], + [weekLabel]: [], + [earlierLabel]: [] }; notifications.forEach((notification) => { @@ -137,13 +145,13 @@ const NotificationsPage = () => { const notifDay = new Date(notifDate.getFullYear(), notifDate.getMonth(), notifDate.getDate()); if (notifDay.getTime() === today.getTime()) { - groups['Сегодня'].push(notification); + groups[todayLabel].push(notification); } else if (notifDay.getTime() === yesterday.getTime()) { - groups['Вчера'].push(notification); + groups[yesterdayLabel].push(notification); } else if (notifDate >= weekAgo) { - groups['За последние 7 дней'].push(notification); + groups[weekLabel].push(notification); } else { - groups['Ранее'].push(notification); + groups[earlierLabel].push(notification); } }); @@ -156,7 +164,7 @@ const NotificationsPage = () => { return (

-

Уведомления

+

{isEn ? 'Notifications' : 'Уведомления'}

{/* Панель действий */}
@@ -171,7 +179,7 @@ const NotificationsPage = () => { : 'bg-gray-100 text-gray-700 hover:bg-gray-200' }`} > - Все ({notifications.length}) + {isEn ? 'All' : 'Все'} ({notifications.length})
@@ -192,7 +200,7 @@ const NotificationsPage = () => { onClick={handleMarkAllAsRead} className="px-4 py-2 rounded-md text-sm font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors" > - Прочитать все + {isEn ? 'Mark all as read' : 'Прочитать все'} )} {notifications.some((n) => n.isRead) && ( @@ -200,7 +208,7 @@ const NotificationsPage = () => { onClick={handleDeleteAllRead} className="px-4 py-2 rounded-md text-sm font-medium bg-red-100 text-red-700 hover:bg-red-200 transition-colors" > - Удалить прочитанные + {isEn ? 'Delete read' : 'Удалить прочитанные'} )}
@@ -214,16 +222,16 @@ const NotificationsPage = () => {

- Подключите Push-уведомления + {isEn ? 'Enable Push Notifications' : 'Подключите Push-уведомления'}

- Получайте мгновенные уведомления на компьютер или телефон при важных событиях + {isEn ? 'Get instant notifications on your device for important events' : 'Получайте мгновенные уведомления на компьютер или телефон при важных событиях'}

@@ -240,11 +248,13 @@ const NotificationsPage = () => { Push-уведомления заблокированы

- Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера. + {isEn ? 'You have blocked notifications for this site. To enable them, allow notifications in your browser settings.' : 'Вы заблокировали уведомления для этого сайта. Чтобы включить их, разрешите уведомления в настройках браузера.'}

- Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить
- Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки + {isEn + ? <>Chrome/Edge: Click the lock icon to the left of the address bar → Notifications → Allow
Firefox: Settings → Privacy & Security → Permissions → Notifications → Settings + : <>Chrome/Edge: Нажмите на иконку замка слева от адресной строки → Уведомления → Разрешить
Firefox: Настройки → Приватность и защита → Разрешения → Уведомления → Настройки + }

@@ -261,9 +271,11 @@ const NotificationsPage = () => { -

Нет уведомлений

+

{isEn ? 'No notifications' : 'Нет уведомлений'}

- {filter === 'unread' ? 'Все уведомления прочитаны' : 'У вас пока нет уведомлений'} + {filter === 'unread' + ? (isEn ? 'All notifications are read' : 'Все уведомления прочитаны') + : (isEn ? 'You have no notifications yet' : 'У вас пока нет уведомлений')}

) : ( diff --git a/ospabhost/frontend/src/pages/dashboard/settings.tsx b/ospabhost/frontend/src/pages/dashboard/settings.tsx index b38fa1c..d511e84 100644 --- a/ospabhost/frontend/src/pages/dashboard/settings.tsx +++ b/ospabhost/frontend/src/pages/dashboard/settings.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, createContext, useContext } from 'react'; import axios from 'axios'; import { API_URL } from '../../config/api'; import apiClient from '../../utils/apiClient'; +import { useTranslation } from '../../i18n'; import { getProfile, updateProfile, @@ -30,10 +31,16 @@ import { type TabType = 'profile' | 'security' | 'notifications' | 'api' | 'ssh' | 'delete'; +// Context for sharing isEn across settings tabs +const SettingsLangContext = createContext(false); +const useSettingsLang = () => useContext(SettingsLangContext); + const SettingsPage = () => { const [activeTab, setActiveTab] = useState('profile'); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); + const { locale } = useTranslation(); + const isEn = locale === 'en'; useEffect(() => { loadProfile(); @@ -60,21 +67,22 @@ const SettingsPage = () => { } const tabs = [ - { id: 'profile' as TabType, label: 'Профиль' }, - { id: 'security' as TabType, label: 'Безопасность' }, - { id: 'notifications' as TabType, label: 'Уведомления' }, - { id: 'api' as TabType, label: 'API ключи' }, - { id: 'ssh' as TabType, label: 'SSH ключи' }, - { id: 'delete' as TabType, label: 'Удаление' }, + { id: 'profile' as TabType, label: isEn ? 'Profile' : 'Профиль' }, + { id: 'security' as TabType, label: isEn ? 'Security' : 'Безопасность' }, + { id: 'notifications' as TabType, label: isEn ? 'Notifications' : 'Уведомления' }, + { id: 'api' as TabType, label: isEn ? 'API Keys' : 'API ключи' }, + { id: 'ssh' as TabType, label: isEn ? 'SSH Keys' : 'SSH ключи' }, + { id: 'delete' as TabType, label: isEn ? 'Delete' : 'Удаление' }, ]; return ( +
{/* Заголовок */}
-

Настройки аккаунта

-

Управление профилем, безопасностью и интеграциями

+

{isEn ? 'Account Settings' : 'Настройки аккаунта'}

+

{isEn ? 'Manage profile, security and integrations' : 'Управление профилем, безопасностью и интеграциями'}

@@ -113,11 +121,13 @@ const SettingsPage = () => {
+
); }; // ============ ПРОФИЛЬ ============ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpdate: () => void }) => { + const isEn = useSettingsLang(); const [username, setUsername] = useState(profile?.username || ''); const [email, setEmail] = useState(profile?.email || ''); const [phoneNumber, setPhoneNumber] = useState(profile?.profile?.phoneNumber || ''); @@ -145,29 +155,29 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda try { setSaving(true); await uploadAvatar(avatarFile); - alert('Аватар загружен!'); + alert(isEn ? 'Avatar uploaded!' : 'Аватар загружен!'); onUpdate(); setAvatarFile(null); setAvatarPreview(null); } catch (error) { console.error('Ошибка загрузки аватара:', error); - alert('Ошибка загрузки аватара'); + alert(isEn ? 'Error uploading avatar' : 'Ошибка загрузки аватара'); } finally { setSaving(false); } }; const handleDeleteAvatar = async () => { - if (!confirm('Удалить аватар?')) return; + if (!confirm(isEn ? 'Delete avatar?' : 'Удалить аватар?')) return; try { setSaving(true); await deleteAvatar(); - alert('Аватар удалён'); + alert(isEn ? 'Avatar deleted' : 'Аватар удалён'); onUpdate(); } catch (error) { console.error('Ошибка удаления аватара:', error); - alert('Ошибка удаления аватара'); + alert(isEn ? 'Error deleting avatar' : 'Ошибка удаления аватара'); } finally { setSaving(false); } @@ -177,11 +187,11 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda try { setSaving(true); await updateProfile({ username, email, phoneNumber, timezone, language }); - alert('Профиль обновлён!'); + alert(isEn ? 'Profile updated!' : 'Профиль обновлён!'); onUpdate(); } catch (error) { console.error('Ошибка обновления профиля:', error); - alert('Ошибка обновления профиля'); + alert(isEn ? 'Error updating profile' : 'Ошибка обновления профиля'); } finally { setSaving(false); } @@ -190,13 +200,13 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda return (
-

Профиль

-

Обновите информацию о своём профиле

+

{isEn ? 'Profile' : 'Профиль'}

+

{isEn ? 'Update your profile information' : 'Обновите информацию о своём профиле'}

{/* Аватар */}
-

Аватар

+

{isEn ? 'Avatar' : 'Аватар'}

{avatarPreview || profile?.profile?.avatarUrl ? ( @@ -213,7 +223,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda
@@ -245,10 +255,10 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda {/* Основная информация */}
-

Основная информация

+

{isEn ? 'Basic Information' : 'Основная информация'}

- +
- +
- + setLanguage(e.target.value)} @@ -311,7 +321,7 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda disabled={saving} className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition" > - {saving ? 'Сохранение...' : 'Сохранить изменения'} + {saving ? (isEn ? 'Saving...' : 'Сохранение...') : (isEn ? 'Save changes' : 'Сохранить изменения')}
@@ -320,13 +330,14 @@ const ProfileTab = ({ profile, onUpdate }: { profile: UserProfile | null; onUpda // ============ БЕЗОПАСНОСТЬ ============ const SecurityTab = () => { + const isEn = useSettingsLang(); const [view, setView] = useState<'password' | 'sessions'>('password'); return (
-

Безопасность

-

Управление паролем и активными сеансами

+

{isEn ? 'Security' : 'Безопасность'}

+

{isEn ? 'Manage password and active sessions' : 'Управление паролем и активными сеансами'}

{/* Sub-tabs */} @@ -339,7 +350,7 @@ const SecurityTab = () => { : 'text-gray-600 hover:text-gray-900' }`} > - Смена пароля + {isEn ? 'Change Password' : 'Смена пароля'}
@@ -360,6 +371,7 @@ const SecurityTab = () => { }; const PasswordChange = () => { + const isEn = useSettingsLang(); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -367,11 +379,11 @@ const PasswordChange = () => { const getPasswordStrength = (password: string) => { if (password.length === 0) return { strength: 0, label: '' }; - if (password.length < 6) return { strength: 1, label: 'Слабый', color: 'bg-red-500' }; - if (password.length < 10) return { strength: 2, label: 'Средний', color: 'bg-yellow-500' }; + if (password.length < 6) return { strength: 1, label: isEn ? 'Weak' : 'Слабый', color: 'bg-red-500' }; + if (password.length < 10) return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' }; if (!/[A-Z]/.test(password) || !/[0-9]/.test(password)) - return { strength: 2, label: 'Средний', color: 'bg-yellow-500' }; - return { strength: 3, label: 'Сильный', color: 'bg-green-500' }; + return { strength: 2, label: isEn ? 'Medium' : 'Средний', color: 'bg-yellow-500' }; + return { strength: 3, label: isEn ? 'Strong' : 'Сильный', color: 'bg-green-500' }; }; const strength = getPasswordStrength(newPassword); @@ -380,20 +392,20 @@ const PasswordChange = () => { e.preventDefault(); if (newPassword !== confirmPassword) { - alert('Пароли не совпадают'); + alert(isEn ? 'Passwords do not match' : 'Пароли не совпадают'); return; } try { setLoading(true); await changePassword({ currentPassword, newPassword }); - alert('Пароль успешно изменён!'); + alert(isEn ? 'Password changed successfully!' : 'Пароль успешно изменён!'); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); } catch (error) { console.error('Ошибка смены пароля:', error); - alert('Ошибка смены пароля. Проверьте текущий пароль.'); + alert(isEn ? 'Password change error. Check current password.' : 'Ошибка смены пароля. Проверьте текущий пароль.'); } finally { setLoading(false); } @@ -402,7 +414,7 @@ const PasswordChange = () => { return (
- + {
- + { /> ))}
-

Сила пароля: {strength.label}

+

{isEn ? 'Password strength:' : 'Сила пароля:'} {strength.label}

)}
- + { disabled={loading} className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-medium hover:bg-ospab-accent disabled:opacity-50 transition" > - {loading ? 'Изменение...' : 'Изменить пароль'} + {loading ? (isEn ? 'Changing...' : 'Изменение...') : (isEn ? 'Change password' : 'Изменить пароль')} ); }; const ActiveSessions = () => { + const isEn = useSettingsLang(); const [sessions, setSessions] = useState([]); const [loginHistory, setLoginHistory] = useState([]); const [loading, setLoading] = useState(true); @@ -494,29 +507,29 @@ const ActiveSessions = () => { }; const handleTerminate = async (id: number) => { - if (!confirm('Вы уверены, что хотите завершить эту сессию?')) return; + if (!confirm(isEn ? 'Are you sure you want to terminate this session?' : 'Вы уверены, что хотите завершить эту сессию?')) return; try { await terminateSession(id); - alert('Сеанс завершён'); + alert(isEn ? 'Session terminated' : 'Сеанс завершён'); loadSessions(); } catch (error) { console.error('Ошибка завершения сеанса:', error); - alert('Не удалось завершить сессию'); + alert(isEn ? 'Failed to terminate session' : 'Не удалось завершить сессию'); } }; const handleTerminateAllOthers = async () => { - if (!confirm('Вы уверены, что хотите завершить все остальные сессии?')) return; + if (!confirm(isEn ? 'Are you sure you want to terminate all other sessions?' : 'Вы уверены, что хотите завершить все остальные сессии?')) return; try { // Используем API для завершения всех остальных сессий await apiClient.delete('/api/sessions/others/all'); - alert('Все остальные сессии завершены'); + alert(isEn ? 'All other sessions terminated' : 'Все остальные сессии завершены'); loadSessions(); } catch (error) { console.error('Ошибка завершения сессий:', error); - alert('Не удалось завершить сессии'); + alert(isEn ? 'Failed to terminate sessions' : 'Не удалось завершить сессии'); } }; @@ -535,11 +548,11 @@ const ActiveSessions = () => { const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); - if (diffMins < 1) return 'только что'; - if (diffMins < 60) return `${diffMins} мин. назад`; - if (diffHours < 24) return `${diffHours} ч. назад`; - if (diffDays < 7) return `${diffDays} дн. назад`; - return date.toLocaleDateString('ru-RU'); + if (diffMins < 1) return isEn ? 'just now' : 'только что'; + if (diffMins < 60) return isEn ? `${diffMins} min ago` : `${diffMins} мин. назад`; + if (diffHours < 24) return isEn ? `${diffHours}h ago` : `${diffHours} ч. назад`; + if (diffDays < 7) return isEn ? `${diffDays}d ago` : `${diffDays} дн. назад`; + return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU'); }; if (loading) { @@ -559,7 +572,7 @@ const ActiveSessions = () => { className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2" > 🚫 - Завершить все остальные сессии + {isEn ? 'Terminate all other sessions' : 'Завершить все остальные сессии'}
)} @@ -567,7 +580,7 @@ const ActiveSessions = () => { {/* Сессии в виде карточек */}
{sessions.length === 0 ? ( -

Нет активных сеансов

+

{isEn ? 'No active sessions' : 'Нет активных сеансов'}

) : ( sessions.map((session) => { const isCurrent = session.isCurrent || session.device?.includes('Current'); @@ -583,7 +596,7 @@ const ActiveSessions = () => { {isCurrent && (
- ✓ Текущая сессия + ✓ {isEn ? 'Current Session' : 'Текущая сессия'}
)} @@ -593,12 +606,12 @@ const ActiveSessions = () => {
{getDeviceIcon(session.device || 'desktop')}

- {session.browser || 'Неизвестный браузер'} · {session.device || 'Desktop'} + {session.browser || (isEn ? 'Unknown browser' : 'Неизвестный браузер')} · {session.device || 'Desktop'}

🌐 - {session.ipAddress || 'Неизвестно'} + {session.ipAddress || (isEn ? 'Unknown' : 'Неизвестно')}

{session.location && (

@@ -608,11 +621,11 @@ const ActiveSessions = () => { )}

⏱️ - Активность: {formatRelativeTime(session.lastActivity)} + {isEn ? 'Activity' : 'Активность'}: {formatRelativeTime(session.lastActivity)}

🔐 - Вход: {new Date(session.createdAt || session.lastActivity).toLocaleString('ru-RU')} + {isEn ? 'Login' : 'Вход'}: {new Date(session.createdAt || session.lastActivity).toLocaleString(isEn ? 'en-US' : 'ru-RU')}

@@ -625,7 +638,7 @@ const ActiveSessions = () => { onClick={() => handleTerminate(session.id)} className="w-full bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200" > - Завершить сессию + {isEn ? 'Terminate Session' : 'Завершить сессию'}
)} @@ -644,8 +657,8 @@ const ActiveSessions = () => { className="w-full flex items-center justify-between text-left" >
-

История входов

-

Последние 20 попыток входа в аккаунт

+

{isEn ? 'Login History' : 'История входов'}

+

{isEn ? 'Last 20 login attempts' : 'Последние 20 попыток входа в аккаунт'}

{showHistory ? '▼' : '▶'} @@ -657,16 +670,16 @@ const ActiveSessions = () => { - Статус + {isEn ? 'Status' : 'Статус'} - IP адрес + {isEn ? 'IP Address' : 'IP адрес'} - Устройство + {isEn ? 'Device' : 'Устройство'} - Дата и время + {isEn ? 'Date and Time' : 'Дата и время'} @@ -681,17 +694,17 @@ const ActiveSessions = () => { : 'bg-red-100 text-red-800' }`} > - {entry.success ? '✓ Успешно' : '✗ Ошибка'} + {entry.success ? (isEn ? '✓ Success' : '✓ Успешно') : (isEn ? '✗ Error' : '✗ Ошибка')} {entry.ipAddress} - {entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || 'Неизвестно'} + {entry.userAgent ? entry.userAgent.substring(0, 60) + '...' : entry.device || (isEn ? 'Unknown' : 'Неизвестно')} - {new Date(entry.createdAt).toLocaleString('ru-RU')} + {new Date(entry.createdAt).toLocaleString(isEn ? 'en-US' : 'ru-RU')} ))} @@ -703,12 +716,12 @@ const ActiveSessions = () => { {/* Советы по безопасности */}
-

💡 Советы по безопасности

+

💡 {isEn ? 'Security Tips' : 'Советы по безопасности'}

    -
  • • Регулярно проверяйте список активных сессий
  • -
  • • Завершайте сессии на устройствах, которыми больше не пользуетесь
  • -
  • • Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль
  • -
  • • Используйте надёжные пароли и двухфакторную аутентификацию
  • +
  • • {isEn ? 'Regularly check the list of active sessions' : 'Регулярно проверяйте список активных сессий'}
  • +
  • • {isEn ? 'Terminate sessions on devices you no longer use' : 'Завершайте сессии на устройствах, которыми больше не пользуетесь'}
  • +
  • • {isEn ? 'If you see suspicious activity, immediately terminate all sessions and change password' : 'Если вы видите подозрительную активность, немедленно завершите все сессии и смените пароль'}
  • +
  • • {isEn ? 'Use strong passwords and two-factor authentication' : 'Используйте надёжные пароли и двухфакторную аутентификацию'}
@@ -717,6 +730,7 @@ const ActiveSessions = () => { // ============ УВЕДОМЛЕНИЯ ============ const NotificationsTab = () => { + const isEn = useSettingsLang(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -748,7 +762,7 @@ const NotificationsTab = () => { await updateNotificationSettings({ [field]: value }); } catch (error) { console.error('Ошибка обновления настроек:', error); - alert('Ошибка сохранения настроек'); + alert(isEn ? 'Error saving settings' : 'Ошибка сохранения настроек'); loadSettings(); // Revert } finally { setSaving(false); @@ -760,7 +774,7 @@ const NotificationsTab = () => { } if (!settings) { - return
Ошибка загрузки настроек
; + return
{isEn ? 'Error loading settings' : 'Ошибка загрузки настроек'}
; } const emailSettings = [ @@ -779,13 +793,13 @@ const NotificationsTab = () => { return (
-

Уведомления

-

Настройте способы получения уведомлений

+

{isEn ? 'Notifications' : 'Уведомления'}

+

{isEn ? 'Configure notification methods' : 'Настройте способы получения уведомлений'}

{/* Email уведомления */}
-

Email уведомления

+

{isEn ? 'Email Notifications' : 'Email уведомления'}

{emailSettings.map((setting) => (