Сделан баланс, проверка чеков, начата система создания серверов

This commit is contained in:
Georgiy Syralev
2025-09-18 16:26:11 +03:00
parent 515d31ee9e
commit cce9e7b996
54 changed files with 1914 additions and 316 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Node
node_modules/
# Env files
.env
ospabhost/frontend/.env
ospabhost/backend/.env
# Build
/dist
/build

11
ospabhost/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Node
node_modules/
# Env files
.env
frontend/.env
backend/.env
# Build
/dist
/build

View File

@@ -10,12 +10,14 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.16.2",
"axios": "^1.12.2",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
@@ -23,6 +25,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^20.12.12",
"prisma": "^6.16.2",
"ts-node-dev": "^2.0.0",
@@ -296,10 +299,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.15.tgz",
"integrity": "sha512-W3bqcbLsRdFDVcmAM5l6oLlcl67vjevn8j1FPZ4nx+K5jNoWCh+FC/btxFoBPnvQlrHHDwfjp1kjIEDfwJ0Mog==",
"version": "20.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -410,6 +423,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -423,6 +442,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -521,9 +557,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -617,6 +663,18 @@
"consola": "^3.2.3"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -624,6 +682,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/confbox": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
@@ -723,6 +796,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -870,6 +952,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -992,6 +1089,42 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1155,6 +1288,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1478,7 +1626,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -1503,6 +1650,36 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/multer/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1729,6 +1906,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -1796,6 +1979,20 @@
"destr": "^2.0.3"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2045,6 +2242,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -2260,6 +2474,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
@@ -2290,6 +2510,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -2326,7 +2552,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"

View File

@@ -13,12 +13,14 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.16.2",
"axios": "^1.12.2",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2"
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
@@ -26,6 +28,7 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.23",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.0.0",
"@types/node": "^20.12.12",
"prisma": "^6.16.2",
"ts-node-dev": "^2.0.0",

View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.operatingSystem.deleteMany({ where: { type: 'windows' } });
console.log('Все Windows Server ОС удалены!');
}
main().finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,5 @@
-- AddForeignKey
ALTER TABLE `Ticket` ADD CONSTRAINT `Ticket_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Response` ADD CONSTRAINT `Response_operatorId_fkey` FOREIGN KEY (`operatorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE `Check` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`amount` DOUBLE NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'pending',
`fileUrl` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Check` ADD CONSTRAINT `Check_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `user` ADD COLUMN `balance` DOUBLE NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,45 @@
-- CreateTable
CREATE TABLE `Tariff` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`price` DOUBLE NOT NULL,
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `Tariff_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `OperatingSystem` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`type` VARCHAR(191) NOT NULL,
`template` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `OperatingSystem_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Server` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`tariffId` INTEGER NOT NULL,
`osId` INTEGER NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'stopped',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Server` ADD CONSTRAINT `Server_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Server` ADD CONSTRAINT `Server_tariffId_fkey` FOREIGN KEY (`tariffId`) REFERENCES `Tariff`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Server` ADD CONSTRAINT `Server_osId_fkey` FOREIGN KEY (`osId`) REFERENCES `OperatingSystem`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -1,4 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
@@ -10,14 +9,61 @@ datasource db {
url = env("DATABASE_URL")
}
// This is your Prisma schema file,
model Tariff {
id Int @id @default(autoincrement())
name String @unique
price Float
description String?
createdAt DateTime @default(now())
servers Server[]
}
model OperatingSystem {
id Int @id @default(autoincrement())
name String @unique
type String // linux, windows, etc
template String? // путь к шаблону для контейнера
createdAt DateTime @default(now())
servers Server[]
}
model Server {
id Int @id @default(autoincrement())
userId Int
tariffId Int
osId Int
status String @default("stopped") // running, stopped, etc
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
tariff Tariff @relation(fields: [tariffId], references: [id])
os OperatingSystem @relation(fields: [osId], references: [id])
}
model User {
id Int @id @default(autoincrement())
username String
email String @unique
password String
createdAt DateTime @default(now())
plans Plan[]
operator Int @default(0) // Добавляем новую колонку operator
plans Plan[] @relation("UserPlans")
operator Int @default(0)
tickets Ticket[] @relation("UserTickets")
responses Response[] @relation("OperatorResponses")
checks Check[] @relation("UserChecks")
balance Float @default(0)
servers Server[]
}
model Check {
id Int @id @default(autoincrement())
userId Int
amount Float
status String @default("pending") // pending, approved, rejected
fileUrl String
createdAt DateTime @default(now())
user User @relation("UserChecks", fields: [userId], references: [id])
}
model Plan {
@@ -28,8 +74,8 @@ model Plan {
isCustom Boolean @default(false)
createdAt DateTime @default(now())
userId Int
owner User @relation(fields: [userId], references: [id])
services Service[]
owner User @relation("UserPlans", fields: [userId], references: [id])
services Service[] @relation("PlanServices")
}
model Service {
@@ -37,7 +83,7 @@ model Service {
name String @unique
price Float
planId Int?
plan Plan? @relation(fields: [planId], references: [id])
plan Plan? @relation("PlanServices", fields: [planId], references: [id])
}
model Ticket {
@@ -48,10 +94,8 @@ model Ticket {
status String @default("open")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
responses Response[] // связь
// Если нужна связь с User:
// user User @relation(fields: [userId], references: [id])
responses Response[] @relation("TicketResponses")
user User? @relation("UserTickets", fields: [userId], references: [id])
}
model Response {
@@ -60,5 +104,6 @@ model Response {
operatorId Int
message String
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id]) // <-- обратная связь
ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id])
operator User @relation("OperatorResponses", fields: [operatorId], references: [id])
}

View File

@@ -0,0 +1,27 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const tariffs = [
{ name: 'Минимальный', price: 150, description: '1 ядро, 1ГБ RAM, 20ГБ SSD' },
{ name: 'Базовый', price: 300, description: '2 ядра, 2ГБ RAM, 40ГБ SSD' },
{ name: 'Старт', price: 500, description: '2 ядра, 4ГБ RAM, 60ГБ SSD' },
{ name: 'Оптимальный', price: 700, description: '4 ядра, 4ГБ RAM, 80ГБ SSD' },
{ name: 'Профи', price: 1000, description: '4 ядра, 8ГБ RAM, 120ГБ SSD' },
{ name: 'Бизнес', price: 1500, description: '8 ядер, 16ГБ RAM, 200ГБ SSD' },
{ name: 'Корпоративный', price: 2000, description: '12 ядер, 24ГБ RAM, 300ГБ SSD' },
{ name: 'Премиум', price: 2500, description: '16 ядер, 32ГБ RAM, 400ГБ SSD' },
{ name: 'Энтерпрайз', price: 2800, description: '24 ядра, 48ГБ RAM, 500ГБ SSD' },
{ name: 'Максимум', price: 3000, description: '32 ядра, 64ГБ RAM, 1ТБ SSD' },
];
for (const t of tariffs) {
await prisma.tariff.upsert({
where: { name: t.name },
update: t,
create: t,
});
}
console.log('Тарифы успешно добавлены!');
}
main().finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,24 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const oses = [
{ name: 'Ubuntu 22.04', type: 'linux', template: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst' },
{ name: 'Debian 12', type: 'linux', template: 'local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst' },
{ name: 'CentOS 9', type: 'linux', template: 'local:vztmpl/centos-9-stream-default_20240828_amd64.tar.xz' },
{ name: 'AlmaLinux 9', type: 'linux', template: 'local:vztmpl/almalinux-9-default_20240911_amd64.tar.xz' },
{ name: 'Rocky Linux 9', type: 'linux', template: 'local:vztmpl/rockylinux-9-default_20240912_amd64.tar.xz' },
{ name: 'Arch Linux', type: 'linux', template: 'local:vztmpl/archlinux-base_20240911-1_amd64.tar.zst' },
{ name: 'Fedora 41', type: 'linux', template: 'local:vztmpl/fedora-41-default_20241118_amd64.tar.xz' },
];
for (const os of oses) {
await prisma.operatingSystem.upsert({
where: { name: os.name },
update: os,
create: os,
});
}
console.log('ОС успешно добавлены!');
}
main().finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,2 @@
// Импорт и экспорт функций для работы с Proxmox
export * from './proxmoxApi';

View File

@@ -0,0 +1,18 @@
import { Router } from 'express';
import { createContainer } from './proxmoxApi';
const router = Router();
// Маршрут для создания контейнера
router.post('/container', async (req, res) => {
try {
const { vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize } = req.body;
const result = await createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize });
res.json(result);
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : err });
}
});
export default router;

View File

@@ -0,0 +1,45 @@
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
const PROXMOX_API = `https://${process.env.PROXMOX_HOST}:${process.env.PROXMOX_PORT}/api2/json`;
function getProxmoxHeaders() {
return {
Authorization: `PVEAPIToken=${process.env.PROXMOX_API_TOKEN_ID}=${process.env.PROXMOX_API_TOKEN_SECRET}`,
};
}
// Создание контейнера (LXC) из шаблона
export interface CreateContainerParams {
vmid: number;
hostname: string;
password: string;
ostemplate: string;
storage: string;
cores: number;
memory: number;
rootfsSize: number;
}
export async function createContainer({ vmid, hostname, password, ostemplate, storage, cores, memory, rootfsSize }: CreateContainerParams) {
const url = `${PROXMOX_API}/nodes/${process.env.PROXMOX_NODE}/lxc`;
const data = {
vmid,
hostname,
password,
ostemplate, // например: 'local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst'
storage, // например: 'local'
cores, // количество ядер
memory, // RAM в МБ
rootfs: `${storage}:${rootfsSize}`, // например: 'local:8'
net0: 'name=eth0,bridge=vmbr0,ip=dhcp',
// Дополнительные параметры по необходимости
};
try {
const res = await axios.post(url, data, { headers: getProxmoxHeaders() });
return res.data;
} catch (err) {
throw new Error('Ошибка создания контейнера: ' + (err instanceof Error ? err.message : err));
}
}

View File

@@ -2,6 +2,12 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './modules/auth/auth.routes';
import ticketRoutes from './modules/ticket/ticket.routes';
import checkRoutes from './modules/check/check.routes';
import proxmoxRoutes from '../proxmox/proxmox.routes';
import tariffRoutes from './modules/tariff';
import osRoutes from './modules/os';
import serverRoutes from './modules/server';
dotenv.config();
@@ -30,7 +36,19 @@ app.get('/', (req, res) => {
});
});
// Статические файлы чеков
import path from 'path';
app.use('/uploads/checks', express.static(path.join(__dirname, '../uploads/checks')));
app.use('/api/auth', authRoutes);
app.use('/api/ticket', ticketRoutes);
app.use('/api/check', checkRoutes);
app.use('/api/proxmox', proxmoxRoutes);
app.use('/api/tariff', tariffRoutes);
app.use('/api/os', osRoutes);
app.use('/api/server', serverRoutes);
const PORT = process.env.PORT || 5000;

View File

@@ -67,11 +67,10 @@ export const login = async (req: Request, res: Response) => {
export const getMe = async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: 'Не авторизован.' });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -79,18 +78,19 @@ export const getMe = async (req: Request, res: Response) => {
username: true,
email: true,
createdAt: true,
operator: true, // Добавляем поле operator
operator: true,
balance: true,
servers: true,
tickets: true,
},
});
console.log('API /api/auth/me user:', user);
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден.' });
}
res.status(200).json({ user });
} catch (error) {
console.error('Ошибка при получении данных пользователя:', error);
res.status(500).json({ message: 'Ошибка сервера.' });
}
}
};

View File

@@ -1,13 +1,11 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
interface AuthRequest extends Request {
userId?: number;
}
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
export const authMiddleware = async (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
@@ -20,9 +18,10 @@ export const authMiddleware = (req: AuthRequest, res: Response, next: NextFuncti
}
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
req.userId = decoded.id;
const user = await prisma.user.findUnique({ where: { id: decoded.id } });
if (!user) return res.status(401).json({ message: 'Пользователь не найден.' });
req.user = user;
next();
} catch (error) {
console.error('Ошибка в мидлваре аутентификации:', error);
if (error instanceof jwt.JsonWebTokenError) {

View File

@@ -0,0 +1,66 @@
import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express';
import { Multer } from 'multer';
import path from 'path';
import fs from 'fs';
const prisma = new PrismaClient();
// Тип расширенного запроса с Multer
interface MulterRequest extends Request {
file?: Express.Multer.File;
}
// Загрузка чека клиентом (с файлом)
export async function uploadCheck(req: MulterRequest, res: Response) {
const userId = req.user?.id;
const { amount } = req.body;
const file = req.file;
if (!userId || !amount || !file) return res.status(400).json({ error: 'Данные не заполнены или файл не загружен' });
// Сохраняем путь к файлу
const fileUrl = `/uploads/checks/${file.filename}`;
const check = await prisma.check.create({
data: { userId, amount: Number(amount), fileUrl }
});
res.json(check);
}
// Получить все чеки (оператор)
export async function getChecks(req: Request, res: Response) {
const isOperator = Number(req.user?.operator) === 1;
if (!isOperator) return res.status(403).json({ error: 'Нет прав' });
const checks = await prisma.check.findMany({
include: { user: true },
orderBy: { createdAt: 'desc' }
});
res.json(checks);
}
// Подтвердить чек и пополнить баланс
export async function approveCheck(req: Request, res: Response) {
const { checkId } = req.body;
// Найти чек
const check = await prisma.check.findUnique({ where: { id: checkId } });
if (!check) return res.status(404).json({ error: 'Чек не найден' });
// Обновить статус
await prisma.check.update({ where: { id: checkId }, data: { status: 'approved' } });
// Пополнить баланс пользователя
await prisma.user.update({
where: { id: check.userId },
data: {
balance: {
increment: check.amount
}
}
});
res.json({ success: true });
}
// Отклонить чек
export async function rejectCheck(req: Request, res: Response) {
const { checkId } = req.body;
await prisma.check.update({ where: { id: checkId }, data: { status: 'rejected' } });
res.json({ success: true });
}

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import { uploadCheck, getChecks, approveCheck, rejectCheck } from './check.controller';
import { authMiddleware } from '../auth/auth.middleware';
import multer, { MulterError } from 'multer';
import path from 'path';
const router = Router();
// Настройка Multer для загрузки чеков
const storage = multer.diskStorage({
destination: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) {
cb(null, path.join(__dirname, '../../../uploads/checks'));
},
filename: function (req: Express.Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + file.originalname);
}
});
const allowedMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/jpg'
];
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
// Кастомная ошибка для Multer
const err: any = new Error('Недопустимый формат файла. Разрешены только изображения: jpg, jpeg, png, gif, webp.');
err.code = 'LIMIT_FILE_FORMAT';
cb(err, false);
}
}
});
router.use(authMiddleware);
router.post('/upload', upload.single('file'), uploadCheck);
router.get('/', getChecks);
router.post('/approve', approveCheck);
router.post('/reject', rejectCheck);
export default router;

View File

@@ -0,0 +1,2 @@
import osRoutes from './os.routes';
export default osRoutes;

View File

@@ -0,0 +1,18 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// GET /api/os — получить все ОС
router.get('/', async (req, res) => {
try {
const oses = await prisma.operatingSystem.findMany();
res.json(oses);
} catch (err) {
console.error('Ошибка получения ОС:', err);
res.status(500).json({ error: 'Ошибка получения ОС' });
}
});
export default router;

View File

@@ -0,0 +1,2 @@
import serverRoutes from './server.routes';
export default serverRoutes;

View File

@@ -0,0 +1,70 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
// import { createProxmoxContainer } from '../../proxmox/proxmoxApi'; // если есть интеграция
const router = Router();
const prisma = new PrismaClient();
// POST /api/server/create — создать сервер (контейнер)
router.post('/create', async (req, res) => {
try {
const { tariffId, osId } = req.body;
// TODO: получить userId из авторизации (req.user)
const userId = 1; // временно, заменить на реального пользователя
// Получаем тариф и ОС
const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } });
const os = await prisma.operatingSystem.findUnique({ where: { id: osId } });
if (!tariff || !os) {
return res.status(400).json({ error: 'Тариф или ОС не найдены' });
}
// TODO: интеграция с Proxmox для создания контейнера
// Если интеграция с Proxmox есть, то только при успешном создании контейнера создавать запись в БД
// Например:
// let proxmoxResult;
// try {
// proxmoxResult = await createProxmoxContainer({ ... });
// } catch (proxmoxErr) {
// console.error('Ошибка Proxmox:', proxmoxErr);
// return res.status(500).json({ error: 'Ошибка создания контейнера на Proxmox' });
// }
// Если всё успешно — создаём запись сервера в БД
const server = await prisma.server.create({
data: {
userId,
tariffId,
osId,
status: 'creating',
},
});
res.json({ success: true, server });
} catch (err) {
console.error('Ошибка создания сервера:', err);
// Не создавать сервер, если есть ошибка
return res.status(500).json({ error: 'Ошибка создания сервера' });
}
});
// GET /api/server — получить все серверы пользователя
router.get('/', async (req, res) => {
try {
// TODO: получить userId из авторизации (req.user)
const userId = 1; // временно
const servers = await prisma.server.findMany({
where: { userId },
include: {
os: true,
tariff: true,
},
});
console.log('API /api/server ответ:', servers);
res.json(servers);
} catch (err) {
console.error('Ошибка получения серверов:', err);
res.status(500).json({ error: 'Ошибка получения серверов' });
}
});
export default router;

View File

@@ -0,0 +1,2 @@
import tariffRoutes from './tariff.routes';
export default tariffRoutes;

View File

@@ -0,0 +1,18 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// GET /api/tariff — получить все тарифы
router.get('/', async (req, res) => {
try {
const tariffs = await prisma.tariff.findMany();
res.json(tariffs);
} catch (err) {
console.error('Ошибка получения тарифов:', err);
res.status(500).json({ error: 'Ошибка получения тарифов' });
}
});
export default router;

View File

@@ -0,0 +1,93 @@
import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express';
const prisma = new PrismaClient();
// Расширяем тип Request для user
declare global {
namespace Express {
interface Request {
user?: {
id: number;
operator?: number;
// можно добавить другие поля при необходимости
};
}
}
}
// Создать тикет
export async function createTicket(req: Request, res: Response) {
const { title, message } = req.body;
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
try {
const ticket = await prisma.ticket.create({
data: { title, message, userId },
});
res.json(ticket);
} catch (err) {
res.status(500).json({ error: 'Ошибка создания тикета' });
}
}
// Получить тикеты (клиент — свои, оператор — все)
export async function getTickets(req: Request, res: Response) {
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
try {
const tickets = await prisma.ticket.findMany({
where: isOperator ? {} : { userId },
include: {
responses: { include: { operator: true } },
user: true
},
orderBy: { createdAt: 'desc' },
});
res.json(tickets);
} catch (err) {
res.status(500).json({ error: 'Ошибка получения тикетов' });
}
}
// Ответить на тикет (только оператор)
export async function respondTicket(req: Request, res: Response) {
const { ticketId, message } = req.body;
const operatorId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
if (!operatorId || !isOperator) return res.status(403).json({ error: 'Нет прав' });
try {
const response = await prisma.response.create({
data: { ticketId, operatorId, message },
});
await prisma.ticket.update({
where: { id: ticketId },
data: { status: 'answered' },
});
res.json(response);
} catch (err) {
res.status(500).json({ error: 'Ошибка ответа на тикет' });
}
}
// Закрыть тикет (клиент или оператор)
export async function closeTicket(req: Request, res: Response) {
const { ticketId } = req.body;
const userId = req.user?.id;
const isOperator = Number(req.user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
if (!isOperator && ticket.userId !== userId) return res.status(403).json({ error: 'Нет прав' });
await prisma.ticket.update({
where: { id: ticketId },
data: { status: 'closed' },
});
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Ошибка закрытия тикета' });
}
}

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { createTicket, getTickets, respondTicket, closeTicket } from './ticket.controller';
import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
router.use(authMiddleware);
router.post('/create', createTicket);
router.get('/', getTickets);
router.post('/respond', respondTicket);
router.post('/close', closeTicket);
export default router;

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -5,7 +5,7 @@ import Homepage from './pages/index';
import Dashboard from './pages/dashboard/mainpage';
import Loginpage from './pages/login';
import Registerpage from './pages/register';
import Tariffspage from './pages/tariffs';
import TariffsPage from './pages/tariffs';
import Aboutpage from './pages/about';
import Privateroute from './components/privateroute';
import { AuthProvider } from './context/authcontext';
@@ -17,7 +17,7 @@ function App() {
<Routes>
{/* Обычные страницы с footer */}
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
<Route path="/tariffs" element={<Pagetempl><Tariffspage /></Pagetempl>} />
<Route path="/tariffs" element={<Pagetempl><TariffsPage /></Pagetempl>} />
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />

View File

@@ -1,4 +1,4 @@
import { Navigate } from 'react-router-dom';
import { Navigate, useLocation } from 'react-router-dom';
import React from 'react';
import useAuth from '../context/useAuth';
@@ -8,7 +8,8 @@ interface PrivateRouteProps {
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
const { isLoggedIn } = useAuth();
return isLoggedIn ? children : <Navigate to="/login" replace />;
const location = useLocation();
return isLoggedIn ? children : <Navigate to="/login" replace state={{ from: location }} />;
};
export default PrivateRoute;

View File

@@ -2,8 +2,12 @@
import { createContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import type { UserData } from '../pages/dashboard/types';
interface AuthContextType {
isLoggedIn: boolean;
userData: UserData | null;
setUserData: (data: UserData | null) => void;
login: (token: string) => void;
logout: () => void;
}
@@ -11,6 +15,8 @@ interface AuthContextType {
// Создаем контекст с начальными значениями
const AuthContext = createContext<AuthContextType>({
isLoggedIn: false,
userData: null,
setUserData: () => {},
login: () => {},
logout: () => {},
});
@@ -22,11 +28,12 @@ interface AuthProviderProps {
// Создаем провайдер, который будет управлять состоянием
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [userData, setUserData] = useState<UserData | null>(null);
// Проверяем статус входа при загрузке приложения
useEffect(() => {
const token = localStorage.getItem('access_token');
setIsLoggedIn(!!token);
// Можно добавить загрузку userData при наличии токена
}, []);
const login = (token: string) => {
@@ -37,10 +44,11 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
const logout = () => {
localStorage.removeItem('access_token');
setIsLoggedIn(false);
setUserData(null);
};
return (
<AuthContext.Provider value={{ isLoggedIn, login, logout }}>
<AuthContext.Provider value={{ isLoggedIn, userData, setUserData, login, logout }}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,7 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import App from './app.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -1,41 +1,62 @@
// 3. Исправляем frontend/src/pages/dashboard/billing.tsx
import { useState } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import QRCode from 'react-qr-code';
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
const Billing = () => {
const [amount, setAmount] = useState(0);
const [isPaymentGenerated, setIsPaymentGenerated] = useState(false);
const [copyStatus, setCopyStatus] = useState('');
// ИСПРАВЛЕНО: используем правильные переменные окружения для Vite
const cardNumber = import.meta.env.VITE_CARD_NUMBER || '';
const sbpUrl = import.meta.env.VITE_SBP_QR_URL || '';
const [checkFile, setCheckFile] = useState<File | null>(null);
const [checkStatus, setCheckStatus] = useState('');
const [uploadLoading, setUploadLoading] = useState(false);
const handleGeneratePayment = () => {
if (amount <= 0) {
alert('Пожалуйста, введите сумму больше нуля.');
return;
}
if (!cardNumber || !sbpUrl) {
alert('Данные для оплаты не настроены. Пожалуйста, обратитесь к администратору.');
return;
}
setIsPaymentGenerated(true);
if (amount > 0) setIsPaymentGenerated(true);
};
const handleCopyCard = () => {
if (cardNumber) {
navigator.clipboard.writeText(cardNumber);
setCopyStatus('Номер карты скопирован!');
setCopyStatus('Скопировано!');
setTimeout(() => setCopyStatus(''), 2000);
}
};
const handleCheckUpload = async () => {
if (!checkFile || amount <= 0) return;
setUploadLoading(true);
try {
const token = localStorage.getItem('token');
const formData = new FormData();
formData.append('file', checkFile);
formData.append('amount', String(amount));
const response = await axios.post('http://localhost:5000/api/check/upload', formData, {
headers: {
Authorization: `Bearer ${token}`,
// 'Content-Type' не указываем вручную для FormData!
},
withCredentials: true,
});
setCheckStatus('Чек успешно загружен! Ожидайте проверки.');
setCheckFile(null);
console.log('Чек успешно загружен:', response.data);
} catch (error) {
setCheckStatus('Ошибка загрузки чека.');
console.error('Ошибка загрузки чека:', error);
}
setUploadLoading(false);
};
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-2xl mx-auto">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Пополнение баланса</h2>
{/* Только QR-код и карта, без реквизитов */}
{!isPaymentGenerated ? (
<div>
<p className="text-lg text-gray-500 mb-4">
@@ -61,52 +82,53 @@ const Billing = () => {
</div>
) : (
<div className="text-center">
<p className="text-lg text-gray-700 mb-4">
Для пополнения баланса переведите <strong>{amount}</strong>.
</p>
<p className="text-sm text-gray-500 mb-6">
Ваш заказ будет обработан вручную после проверки чека.
</p>
{sbpUrl && (
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
<div className="flex justify-center p-4 bg-white rounded-lg">
<QRCode value={sbpUrl} size={256} />
</div>
<p className="mt-4 text-sm text-gray-600">
Отсканируйте QR-код через мобильное приложение вашего банка.
</p>
<div>
<p className="text-lg text-gray-700 mb-4">
Для пополнения баланса переведите <strong>{amount}</strong>.
</p>
<p className="text-sm text-gray-500 mb-6">
Ваш заказ будет обработан вручную после проверки чека.
</p>
</div>
{/* QR-код для оплаты по СБП */}
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
<div className="flex justify-center p-4 bg-white rounded-lg">
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE-QR-LINK'} size={256} />
</div>
)}
{cardNumber && (
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber}</p>
<button
onClick={handleCopyCard}
className="mt-4 px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-gray-700"
>
Скопировать номер карты
</button>
{copyStatus && <p className="mt-2 text-sm text-green-500">{copyStatus}</p>}
</div>
)}
<p className="mt-4 text-sm text-gray-600">
Отсканируйте QR-код через мобильное приложение вашего банка.
</p>
</div>
{/* Номер карты с кнопкой копирования */}
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber || '0000 0000 0000 0000'}</p>
<button
onClick={handleCopyCard}
className="mt-4 px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-gray-700"
>
Скопировать номер карты
</button>
{copyStatus && <p className="mt-2 text-sm text-green-500">{copyStatus}</p>}
</div>
{/* Форма загрузки чека и инструкции */}
<div className="bg-blue-50 p-6 rounded-2xl border-l-4 border-blue-500 text-left mb-6">
<p className="font-bold text-blue-800">Загрузите чек для проверки:</p>
<input type="file" accept="image/*,application/pdf" onChange={e => setCheckFile(e.target.files?.[0] || null)} className="mt-2" />
<button onClick={handleCheckUpload} disabled={!checkFile || uploadLoading} className="mt-2 bg-blue-500 text-white px-4 py-2 rounded">
{uploadLoading ? 'Загрузка...' : 'Отправить чек'}
</button>
{checkStatus && <div className="mt-2 text-green-600">{checkStatus}</div>}
</div>
<div className="bg-red-50 p-6 rounded-2xl border-l-4 border-red-500 text-left mb-6">
<p className="font-bold text-red-800">Важно:</p>
<p className="text-sm text-red-700">
После оплаты сделайте скриншот или сохраните чек и отправьте его нам в тикет поддержки.
После оплаты сделайте скриншот или сохраните чек и загрузите его для проверки.
</p>
</div>
<p className="mt-4 text-gray-600">
После подтверждения ваш баланс будет пополнен. Перейдите в раздел{' '}
<Link to="/dashboard/tickets" className="text-ospab-primary font-bold hover:underline">
Тикеты
</Link>
, чтобы отправить нам чек.
После подтверждения ваш баланс будет пополнен. Ожидайте проверки чека оператором.
</p>
</div>
)}

View File

@@ -0,0 +1,122 @@
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import axios from 'axios';
interface Tariff {
id: number;
name: string;
price: number;
description?: string;
}
interface OperatingSystem {
id: number;
name: string;
type: string;
template?: string;
}
interface CheckoutProps {
onSuccess: () => void;
}
const Checkout: React.FC<CheckoutProps> = ({ onSuccess }) => {
const [tariffs, setTariffs] = useState<Tariff[]>([]);
const [oses, setOses] = useState<OperatingSystem[]>([]);
const [selectedTariff, setSelectedTariff] = useState<number | null>(null);
const [selectedOs, setSelectedOs] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const location = useLocation();
useEffect(() => {
// Загрузка тарифов и ОС
const fetchData = async () => {
try {
const [tariffRes, osRes] = await Promise.all([
axios.get('http://localhost:5000/api/tariff'),
axios.get('http://localhost:5000/api/os'),
]);
setTariffs(tariffRes.data);
setOses(osRes.data);
// Автовыбор тарифа из query
const params = new URLSearchParams(location.search);
const tariffId = params.get('tariff');
if (tariffId) {
setSelectedTariff(Number(tariffId));
}
} catch {
setError('Ошибка загрузки тарифов или ОС');
}
};
fetchData();
}, [location.search]);
const handleBuy = async () => {
if (!selectedTariff || !selectedOs) {
setError('Выберите тариф и ОС');
return;
}
setLoading(true);
setError('');
try {
console.log('Покупка сервера:', { tariffId: selectedTariff, osId: selectedOs });
const res = await axios.post('http://localhost:5000/api/server/create', {
tariffId: selectedTariff,
osId: selectedOs,
});
console.log('Ответ сервера:', res.data);
onSuccess();
} catch (err) {
console.error('Ошибка покупки сервера:', err);
setError('Ошибка покупки сервера');
}
setLoading(false);
};
return (
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-xl mx-auto">
<h2 className="text-2xl font-bold mb-4">Покупка сервера</h2>
{error && <p className="text-red-500 mb-2">{error}</p>}
<div className="mb-4">
<label className="block font-semibold mb-2">Тариф:</label>
<select
className="w-full border rounded px-3 py-2"
value={selectedTariff ?? ''}
onChange={e => setSelectedTariff(Number(e.target.value))}
>
<option value="">Выберите тариф</option>
{tariffs.map(t => (
<option key={t.id} value={t.id}>
{t.name} {t.price}
</option>
))}
</select>
</div>
<div className="mb-4">
<label className="block font-semibold mb-2">Операционная система:</label>
<select
className="w-full border rounded px-3 py-2"
value={selectedOs ?? ''}
onChange={e => setSelectedOs(Number(e.target.value))}
>
<option value="">Выберите ОС</option>
{oses.map(os => (
<option key={os.id} value={os.id}>
{os.name} ({os.type})
</option>
))}
</select>
</div>
<button
className="bg-ospab-primary text-white px-6 py-2 rounded font-bold w-full"
onClick={handleBuy}
disabled={loading}
>
{loading ? 'Покупка...' : 'Купить сервер'}
</button>
</div>
);
};
export default Checkout;

View File

@@ -1,8 +1,140 @@
const CheckVerification = () => {
import React, { useState, useEffect } from 'react';
import axios from 'axios';
interface IUser {
id: number;
username: string;
email: string;
}
interface ICheck {
id: number;
userId: number;
amount: number;
status: 'pending' | 'approved' | 'rejected';
fileUrl: string;
createdAt: string;
user?: IUser;
}
const API_URL = 'http://localhost:5000/api/check';
const CheckVerification: React.FC = () => {
const [checks, setChecks] = useState<ICheck[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [actionLoading, setActionLoading] = useState<number | null>(null);
const [error, setError] = useState<string>('');
useEffect(() => {
const fetchChecks = async (): Promise<void> => {
setLoading(true);
setError('');
try {
const token = localStorage.getItem('token');
const res = await axios.get<ICheck[]>(API_URL, {
headers: { Authorization: `Bearer ${token}` },
withCredentials: true,
});
setChecks(res.data);
} catch {
setError('Ошибка загрузки чеков');
setChecks([]);
}
setLoading(false);
};
fetchChecks();
}, []);
const handleAction = async (checkId: number, action: 'approve' | 'reject'): Promise<void> => {
setActionLoading(checkId);
setError('');
try {
const token = localStorage.getItem('token');
await axios.post(`${API_URL}/${action}`, { checkId }, {
headers: { Authorization: `Bearer ${token}` },
withCredentials: true,
});
setChecks((prevChecks: ICheck[]) => prevChecks.map((c: ICheck) => c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c));
// Если подтверждение — обновить баланс пользователя
if (action === 'approve') {
try {
const userToken = localStorage.getItem('access_token') || token;
const headers = { Authorization: `Bearer ${userToken}` };
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
// Глобально обновить userData через типизированное событие (для Dashboard)
window.dispatchEvent(new CustomEvent<import('./types').UserData>('userDataUpdate', {
detail: {
user: userRes.data.user,
balance: userRes.data.user.balance ?? 0,
servers: userRes.data.user.servers ?? [],
tickets: userRes.data.user.tickets ?? [],
}
}));
} catch (error) {
console.error('Ошибка обновления userData:', error);
}
}
} catch {
setError('Ошибка действия');
}
setActionLoading(null);
};
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Проверка чеков</h2>
<p className="text-lg text-gray-500">Здесь будут отображаться чеки для проверки.</p>
{loading ? (
<p className="text-lg text-gray-500">Загрузка чеков...</p>
) : error ? (
<p className="text-lg text-red-500">{error}</p>
) : checks.length === 0 ? (
<p className="text-lg text-gray-500">Нет чеков для проверки.</p>
) : (
<div className="space-y-6">
{checks.map((check: ICheck) => (
<div key={check.id} className="border rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between bg-gray-50">
<div className="flex-1 min-w-0">
<div className="mb-2">
<span className="font-bold text-gray-800">Пользователь:</span> <span className="text-gray-700">{check.user?.username || check.user?.email}</span>
</div>
<div className="mb-2">
<span className="font-bold text-gray-800">Сумма:</span> <span className="text-gray-700">{check.amount}</span>
</div>
<div className="mb-2">
<span className="font-bold text-gray-800">Статус:</span> <span className={`font-bold ${check.status === 'pending' ? 'text-yellow-600' : check.status === 'approved' ? 'text-green-600' : 'text-red-600'}`}>{check.status === 'pending' ? 'На проверке' : check.status === 'approved' ? 'Подтверждён' : 'Отклонён'}</span>
</div>
<div className="mb-2">
<span className="font-bold text-gray-800">Дата:</span> <span className="text-gray-700">{new Date(check.createdAt).toLocaleString()}</span>
</div>
</div>
<div className="flex flex-col items-center gap-2 md:ml-8">
<a href={`http://localhost:5000${check.fileUrl}`} target="_blank" rel="noopener noreferrer" className="block mb-2">
<img src={`http://localhost:5000${check.fileUrl}`} alt="Чек" className="w-32 h-32 object-contain rounded-xl border" />
</a>
{check.status === 'pending' && (
<>
<button
onClick={() => handleAction(check.id, 'approve')}
disabled={actionLoading === check.id}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-full font-bold mb-2"
>
{actionLoading === check.id ? 'Подтверждение...' : 'Подтвердить'}
</button>
<button
onClick={() => handleAction(check.id, 'reject')}
disabled={actionLoading === check.id}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-bold"
>
{actionLoading === check.id ? 'Отклонение...' : 'Отклонить'}
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -7,16 +7,18 @@ import { useContext } from 'react';
// Импортируем компоненты для вкладок
import Summary from './summary';
import Servers from './servers';
import Tickets from './tickets';
import ServerManagementPage from './servermanagement';
import TicketsPage from './tickets';
import Billing from './billing';
import Settings from './settings';
import CheckVerification from './checkverification';
import TicketResponse from './ticketresponse';
import Checkout from './checkout';
import TariffsPage from '../tariffs';
const Dashboard = () => {
const [userData, setUserData] = useState<import('./types').UserData | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const location = useLocation();
const { logout } = useContext(AuthContext);
@@ -44,15 +46,13 @@ const Dashboard = () => {
navigate('/login');
return;
}
const headers = { Authorization: `Bearer ${token}` };
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
setUserData({
user: userRes.data.user,
balance: 1500,
servers: [],
tickets: [],
balance: userRes.data.user.balance ?? 0,
servers: userRes.data.user.servers ?? [],
tickets: userRes.data.user.tickets ?? [],
});
} catch (err) {
console.error('Ошибка загрузки данных:', err);
@@ -67,35 +67,48 @@ const Dashboard = () => {
fetchData();
}, [logout, navigate]);
// Функция для обновления userData из API
const updateUserData = async () => {
try {
const token = localStorage.getItem('access_token');
if (!token) return;
const headers = { Authorization: `Bearer ${token}` };
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
setUserData({
user: userRes.data.user,
balance: userRes.data.user.balance ?? 0,
servers: userRes.data.user.servers ?? [],
tickets: userRes.data.user.tickets ?? [],
});
} catch (err) {
console.error('Ошибка обновления userData:', err);
}
};
useEffect(() => {
const handleUserDataUpdate = () => {
try {
updateUserData();
} catch (err) {
console.error('Ошибка в обработчике userDataUpdate:', err);
}
};
window.addEventListener('userDataUpdate', handleUserDataUpdate);
return () => {
window.removeEventListener('userDataUpdate', handleUserDataUpdate);
};
}, []);
const isOperator = userData?.user?.operator === 1;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary mx-auto mb-4"></div>
<h1 className="text-2xl text-gray-800">Загрузка...</h1>
</div>
<div className="flex min-h-screen items-center justify-center">
<span className="text-gray-500 text-lg">Загрузка...</span>
</div>
);
}
if (!userData || !userData.user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl text-gray-800 mb-4">Ошибка загрузки данных</h1>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-ospab-primary text-white rounded-lg"
>
Перезагрузить
</button>
</div>
</div>
);
}
const isOperator = userData.user.operator === 1;
return (
<div className="flex min-h-screen bg-gray-50">
{/* Sidebar - фиксированный слева */}
@@ -103,7 +116,7 @@ const Dashboard = () => {
{/* Заголовок сайдбара */}
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-800">
Привет, {userData.user.username}!
Привет, {userData?.user?.username || 'Гость'}!
</h2>
{isOperator && (
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full mt-1">
@@ -111,7 +124,7 @@ const Dashboard = () => {
</span>
)}
<div className="mt-2 text-sm text-gray-600">
Баланс: <span className="font-semibold text-ospab-primary">{userData.balance}</span>
Баланс: <span className="font-semibold text-ospab-primary">{userData?.balance ?? 0}</span>
</div>
</div>
@@ -124,7 +137,6 @@ const Dashboard = () => {
activeTab === 'summary' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3">📊</span>
Сводка
</Link>
<Link
@@ -133,7 +145,6 @@ const Dashboard = () => {
activeTab === 'servers' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3">🖥</span>
Серверы
</Link>
<Link
@@ -142,7 +153,6 @@ const Dashboard = () => {
activeTab === 'tickets' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3">🎫</span>
Тикеты
</Link>
<Link
@@ -151,7 +161,6 @@ const Dashboard = () => {
activeTab === 'billing' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3">💳</span>
Пополнить баланс
</Link>
<Link
@@ -160,7 +169,6 @@ const Dashboard = () => {
activeTab === 'settings' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3"></span>
Настройки
</Link>
</div>
@@ -177,7 +185,6 @@ const Dashboard = () => {
activeTab === 'checkverification' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3"></span>
Проверка чеков
</Link>
<Link
@@ -186,7 +193,6 @@ const Dashboard = () => {
activeTab === 'ticketresponse' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="mr-3">💬</span>
Ответы на тикеты
</Link>
</div>
@@ -207,7 +213,7 @@ const Dashboard = () => {
<div className="flex-1 flex flex-col">
{/* Хлебные крошки/заголовок */}
<div className="bg-white border-b border-gray-200 px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 capitalize">
{activeTab === 'summary' ? 'Сводка' :
@@ -228,32 +234,22 @@ const Dashboard = () => {
})}
</p>
</div>
{/* Быстрые действия */}
<div className="flex space-x-3">
<Link
to="/dashboard/billing"
className="px-4 py-2 bg-green-100 text-green-800 rounded-lg text-sm font-medium hover:bg-green-200 transition-colors"
>
💰 Пополнить
</Link>
<Link
to="/dashboard/tickets"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
>
🆘 Поддержка
</Link>
</div>
</div>
</div>
{/* Контент страницы */}
<div className="flex-1 p-8">
<Routes>
<Route path="/" element={<Summary userData={userData} />} />
<Route path="servers" element={<Servers servers={userData.servers} />} />
<Route path="tickets" element={<Tickets tickets={userData.tickets} />} />
<Route path="billing" element={<Billing />} />
<Route path="/" element={<Summary userData={userData ?? { user: { username: '', operator: 0 }, balance: 0, servers: [], tickets: [] }} />} />
<Route path="servers" element={<ServerManagementPage />} />
<Route path="checkout" element={<Checkout onSuccess={() => window.location.reload()} />} />
<Route path="tariffs" element={<TariffsPage />} />
{userData && (
<Route path="tickets" element={<TicketsPage setUserData={setUserData} />} />
)}
{userData && (
<Route path="billing" element={<Billing />} />
)}
<Route path="settings" element={<Settings />} />
{isOperator && (

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import useAuth from '../../context/useAuth';
interface Server {
id: number;
status: string;
createdAt: string;
updatedAt: string;
tariff: { name: string; price: number };
os: { name: string; type: string };
}
const ServerManagementPage = () => {
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { isLoggedIn } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (!isLoggedIn) {
navigate('/login');
return;
}
const fetchServers = async () => {
try {
const token = localStorage.getItem('access_token');
const res = await axios.get('http://localhost:5000/api/server', {
headers: { Authorization: `Bearer ${token}` },
});
setServers(res.data);
} catch {
setError('Ошибка загрузки серверов');
setServers([]);
}
setLoading(false);
};
fetchServers();
}, [isLoggedIn, navigate]);
// TODO: добавить управление сервером (включить, выключить, перезагрузить, переустановить ОС)
try {
return (
<div className="min-h-screen bg-gray-50 py-20">
<div className="container mx-auto px-4">
<h1 className="text-4xl font-bold text-center mb-10 text-gray-900">Управление серверами</h1>
{loading ? (
<p className="text-lg text-gray-500 text-center">Загрузка...</p>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
<p className="text-lg text-red-500">{error}</p>
<button className="mt-4 px-4 py-2 rounded bg-ospab-primary text-white font-bold" onClick={() => window.location.reload()}>Перезагрузить</button>
</div>
) : servers.length === 0 ? (
<p className="text-lg text-gray-500 text-center">У вас нет серверов.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{servers.map(server => (
<div key={server.id} className="bg-white p-8 rounded-2xl shadow-xl flex flex-col gap-4">
<div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">{server.tariff.name}</h2>
<p className="text-lg text-gray-600">ОС: {server.os.name} ({server.os.type})</p>
<p className="text-lg text-gray-600">Статус: <span className="font-bold">{server.status}</span></p>
<p className="text-sm text-gray-400">Создан: {new Date(server.createdAt).toLocaleString()}</p>
</div>
{/* TODO: Кнопки управления сервером */}
<div className="flex gap-3 mt-4">
<button className="px-4 py-2 rounded bg-ospab-primary text-white font-bold hover:bg-ospab-accent">Включить</button>
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Выключить</button>
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Перезагрузить</button>
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Переустановить ОС</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
} catch {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-2xl shadow-xl text-center">
<h2 className="text-2xl font-bold text-red-600 mb-4">Ошибка отображения страницы</h2>
<p className="text-gray-700 mb-4">Произошла критическая ошибка. Попробуйте перезагрузить страницу.</p>
<button className="px-4 py-2 rounded bg-ospab-primary text-white font-bold" onClick={() => window.location.reload()}>Перезагрузить</button>
</div>
</div>
);
}
};
export default ServerManagementPage;

View File

@@ -1,17 +1,70 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import axios from 'axios';
interface ServersProps {
servers: unknown[];
interface Server {
id: number;
status: string;
createdAt: string;
updatedAt: string;
os: { name: string; type: string };
tariff: { name: string; price: number };
}
const Servers: React.FC<ServersProps> = ({ servers }) => {
const Servers: React.FC = () => {
const [servers, setServers] = useState<Server[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchServers = async () => {
try {
const res = await axios.get('http://localhost:5000/api/server');
console.log('Ответ API серверов:', res.data);
// Защита от получения HTML вместо JSON
if (typeof res.data === 'string' && res.data.startsWith('<!doctype html')) {
setError('Ошибка соединения с backend: получен HTML вместо JSON. Проверьте адрес и порт.');
setServers([]);
} else if (Array.isArray(res.data)) {
setServers(res.data);
} else {
setError('Некорректный формат данных серверов');
setServers([]);
}
} catch (err) {
console.error('Ошибка загрузки серверов:', err);
setError('Ошибка загрузки серверов');
setServers([]);
}
setLoading(false);
};
fetchServers();
}, []);
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Серверы</h2>
{servers.length === 0 ? (
<p className="text-lg text-gray-500">У вас пока нет активных серверов.</p>
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold text-gray-800">Серверы</h2>
{/* Кнопка 'Купить сервер' только если серверов нет */}
{servers.length === 0 && !loading && !error && (
<a href="/tariffs" className="bg-ospab-primary text-white px-4 py-2 rounded font-bold hover:bg-ospab-primary-dark transition">Купить сервер</a>
)}
</div>
{loading ? (
<p className="text-lg text-gray-500">Загрузка...</p>
) : error ? (
<div className="text-center">
<p className="text-lg text-red-500 mb-4">{error}</p>
<button className="bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition" onClick={() => window.location.reload()}>Перезагрузить страницу</button>
</div>
) : servers.length === 0 ? (
<div className="text-center">
<p className="text-lg text-gray-500 mb-4">У вас пока нет активных серверов.</p>
<a href="/tariffs" className="inline-block bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition">Посмотреть тарифы</a>
</div>
) : (
<p className="text-lg text-gray-500">Список ваших серверов будет здесь...</p>
<div className="text-center">
<a href="/dashboard/servermanagement" className="bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition">Перейти к управлению серверами</a>
</div>
)}
</div>
);

View File

@@ -1,29 +1,37 @@
import { Link } from 'react-router-dom';
import type { UserData } from './types';
import type { UserData, Ticket, Server } from './types';
interface SummaryProps {
userData: UserData;
}
const Summary = ({ userData }: SummaryProps) => {
// Фильтрация открытых тикетов и активных серверов
const openTickets = Array.isArray(userData.tickets)
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
: [];
const activeServers = Array.isArray(userData.servers)
? userData.servers.filter((s: Server) => s.status === 'active')
: [];
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Сводка по аккаунту</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
<p className="text-xl font-medium text-gray-700">Баланс:</p>
<p className="text-4xl font-extrabold text-ospab-primary mt-2"> {userData.balance.toFixed(2)}</p>
<p className="text-4xl font-extrabold text-ospab-primary mt-2"> {userData.balance?.toFixed ? userData.balance.toFixed(2) : Number(userData.balance).toFixed(2)}</p>
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">Пополнить баланс </Link>
</div>
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
<p className="text-xl font-medium text-gray-700">Активные серверы:</p>
<p className="text-4xl font-extrabold text-gray-800 mt-2">{userData.servers.length}</p>
<p className="text-4xl font-extrabold text-gray-800 mt-2">{activeServers.length}</p>
<Link to="/dashboard/servers" className="text-sm text-gray-500 hover:underline mt-2">Управлять </Link>
</div>
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
<p className="text-xl font-medium text-gray-700">Открытые тикеты:</p>
<p className="text-4xl font-extrabold text-gray-800 mt-2">{userData.tickets.length}</p>
<p className="text-4xl font-extrabold text-gray-800 mt-2">{openTickets.length}</p>
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">Служба поддержки </Link>
</div>
</div>

View File

@@ -1,8 +1,155 @@
const TicketResponse = () => {
import React, { useEffect, useState } from 'react';
import axios from 'axios';
interface Response {
id: number;
message: string;
createdAt: string;
operator?: { username: string };
}
interface Ticket {
id: number;
title: string;
message: string;
status: string;
createdAt: string;
responses: Response[];
user?: { username: string };
}
const TicketResponse: React.FC = () => {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [responseMsg, setResponseMsg] = useState<{ [key: number]: string }>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetchTickets();
}, []);
const fetchTickets = async () => {
setError('');
try {
const token = localStorage.getItem('token');
const res = await axios.get('http://localhost:5000/api/ticket', {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (Array.isArray(res.data)) {
setTickets(res.data);
} else {
setTickets([]);
}
} catch {
setError('Ошибка загрузки тикетов');
setTickets([]);
}
};
const respondTicket = async (ticketId: number) => {
setLoading(true);
setError('');
try {
const token = localStorage.getItem('token');
await axios.post('http://localhost:5000/api/ticket/respond', {
ticketId,
message: responseMsg[ticketId]
}, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
setResponseMsg(prev => ({ ...prev, [ticketId]: '' }));
fetchTickets();
} catch {
setError('Ошибка отправки ответа');
} finally {
setLoading(false);
}
};
// Функция закрытия тикета
const closeTicket = async (ticketId: number) => {
setLoading(true);
setError('');
try {
const token = localStorage.getItem('token');
await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
fetchTickets();
} catch {
setError('Ошибка закрытия тикета');
} finally {
setLoading(false);
}
};
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Ответы на тикеты</h2>
<p className="text-lg text-gray-500">Здесь будут отображаться тикеты для ответов.</p>
{error && <div className="text-red-500 mb-4">{error}</div>}
{tickets.length === 0 ? (
<p className="text-lg text-gray-500">Нет тикетов для ответа.</p>
) : (
<div className="space-y-6">
{tickets.map(ticket => (
<div key={ticket.id} className="border rounded-xl p-4 shadow flex flex-col">
<div className="font-bold text-lg mb-1">{ticket.title}</div>
<div className="text-gray-600 mb-2">{ticket.message}</div>
<div className="text-sm text-gray-400 mb-2">Статус: {ticket.status} | Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}</div>
{/* Чат сообщений */}
<div className="flex flex-col gap-2 mb-4">
<div className="flex items-start gap-2">
<div className="bg-blue-100 text-blue-900 px-3 py-2 rounded-xl max-w-xl">
<span className="font-semibold">{ticket.user?.username || 'Клиент'}:</span> {ticket.message}
</div>
</div>
{(ticket.responses || []).map(r => (
<div key={r.id} className="flex items-start gap-2">
<div className="bg-green-100 text-green-900 px-3 py-2 rounded-xl max-w-xl ml-8">
<span className="font-semibold">{r.operator?.username || 'Оператор'}:</span> {r.message}
<span className="text-gray-400 ml-2 text-xs">{new Date(r.createdAt).toLocaleString()}</span>
</div>
</div>
))}
</div>
{/* Форма ответа и кнопка закрытия */}
{ticket.status !== 'closed' && (
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
<input
value={responseMsg[ticket.id] || ''}
onChange={e => setResponseMsg(prev => ({ ...prev, [ticket.id]: e.target.value }))}
placeholder="Ваш ответ..."
className="border rounded p-2 flex-1"
disabled={loading}
/>
<button
type="button"
onClick={() => respondTicket(ticket.id)}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition"
disabled={loading || !(responseMsg[ticket.id] && responseMsg[ticket.id].trim())}
>
{loading ? 'Отправка...' : 'Ответить'}
</button>
<button
type="button"
onClick={() => closeTicket(ticket.id)}
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition"
disabled={loading}
>
Закрыть тикет
</button>
</div>
)}
{ticket.status === 'closed' && (
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
)}
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -1,20 +1,220 @@
import React from 'react';
import type { UserData, Ticket } from './types';
import React, { useEffect, useState } from 'react';
import useAuth from '../../context/useAuth';
import axios from 'axios';
interface TicketsProps {
tickets: unknown[];
}
// Глобальный логгер ошибок для axios
axios.interceptors.response.use(
response => response,
error => {
if (error.response) {
console.error('Ошибка ответа:', error.response.data);
} else if (error.request) {
console.error('Нет ответа от сервера:', error.request);
} else {
console.error('Ошибка запроса:', error.message);
}
return Promise.reject(error);
}
);
type TicketsPageProps = {
setUserData: (data: UserData) => void;
};
const TicketsPage: React.FC<TicketsPageProps> = ({ setUserData }) => {
const { user } = useAuth() as { user?: { username: string; operator?: number } };
const [tickets, setTickets] = useState<Ticket[]>([]);
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [formError, setFormError] = useState('');
const [formSuccess, setFormSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [responseMsg, setResponseMsg] = useState('');
useEffect(() => {
fetchTickets();
}, []);
const fetchTickets = async () => {
try {
const token = localStorage.getItem('token');
const res = await axios.get('http://localhost:5000/api/ticket', {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (Array.isArray(res.data)) {
setTickets(res.data);
} else {
setTickets([]);
}
} catch {
setTickets([]);
}
};
const updateUserData = async () => {
try {
const token = localStorage.getItem('access_token') || localStorage.getItem('token');
if (!token) return;
const headers = { Authorization: `Bearer ${token}` };
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
setUserData({
user: userRes.data.user,
balance: userRes.data.user.balance ?? 0,
servers: userRes.data.user.servers ?? [],
tickets: userRes.data.user.tickets ?? [],
});
} catch (err) {
console.error('Ошибка обновления userData после тикета:', err);
}
};
const createTicket = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
setFormSuccess('');
if (!title.trim() || !message.trim()) {
setFormError('Заполните тему и сообщение');
return;
}
setLoading(true);
try {
const token = localStorage.getItem('token');
await axios.post('http://localhost:5000/api/ticket/create', { title, message }, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
setTitle('');
setMessage('');
setFormSuccess('Тикет успешно создан!');
fetchTickets();
await updateUserData();
} catch {
setFormError('Ошибка создания тикета');
} finally {
setLoading(false);
}
};
const respondTicket = async (ticketId: number) => {
const token = localStorage.getItem('token');
await axios.post('http://localhost:5000/api/ticket/respond', { ticketId, message: responseMsg }, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
setResponseMsg('');
fetchTickets();
await updateUserData();
};
const closeTicket = async (ticketId: number) => {
const token = localStorage.getItem('token');
await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
fetchTickets();
await updateUserData();
};
const Tickets: React.FC<TicketsProps> = ({ tickets }) => {
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Тикеты поддержки</h2>
{tickets.length === 0 ? (
<p className="text-lg text-gray-500">У вас пока нет открытых тикетов.</p>
) : (
<p className="text-lg text-gray-500">Список ваших тикетов будет здесь...</p>
)}
<h2 className="text-3xl font-bold text-gray-800 mb-6">Мои тикеты</h2>
<form onSubmit={createTicket} className="mb-8 max-w-xl bg-gray-50 rounded-2xl shadow p-6 flex flex-col gap-4">
<label className="font-semibold text-lg">Тема тикета</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Введите тему..."
className="border rounded-xl p-3 focus:outline-blue-400 text-base"
/>
<label className="font-semibold text-lg">Сообщение</label>
<textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Опишите проблему или вопрос..."
className="border rounded-xl p-3 min-h-[80px] resize-y focus:outline-blue-400 text-base"
/>
{formError && <div className="text-red-500 text-sm">{formError}</div>}
{formSuccess && <div className="text-green-600 text-sm">{formSuccess}</div>}
<button
type="submit"
className={`bg-blue-500 text-white px-6 py-3 rounded-xl hover:bg-blue-600 transition text-lg font-semibold ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={loading}
>
{loading ? 'Отправка...' : 'Создать тикет'}
</button>
</form>
<div className="space-y-8">
{tickets.map(ticket => (
<div key={ticket.id} className="border rounded-2xl p-6 shadow flex flex-col">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-2">
<div className="font-bold text-xl text-blue-900">{ticket.title}</div>
<div className="text-sm text-gray-500">Статус: <span className={ticket.status === 'closed' ? 'text-red-600 font-bold' : 'text-green-600 font-bold'}>{ticket.status === 'closed' ? 'Закрыт' : 'Открыт'}</span></div>
</div>
<div className="text-sm text-gray-400 mb-2">Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}</div>
{/* Чат сообщений */}
<div className="flex flex-col gap-2 mb-4">
<div className="flex items-start gap-2">
<div className="bg-blue-100 text-blue-900 px-3 py-2 rounded-xl max-w-xl">
<span className="font-semibold">{ticket.user?.username || 'Клиент'}:</span> {ticket.message}
</div>
</div>
{((Array.isArray(ticket.responses) ? ticket.responses : []) as {
id: number;
operator?: { username?: string };
message: string;
createdAt: string;
}[]).map((r) => (
<div key={r.id} className="flex items-start gap-2">
<div className="bg-green-100 text-green-900 px-3 py-2 rounded-xl max-w-xl ml-8">
<span className="font-semibold">{r.operator?.username || 'Оператор'}:</span> {r.message}
<span className="text-gray-400 ml-2 text-xs">{new Date(r.createdAt).toLocaleString()}</span>
</div>
</div>
))}
</div>
{/* Форма ответа и кнопка закрытия */}
{ticket.status !== 'closed' && (
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
{user?.operator === 1 && (
<>
<input
value={responseMsg}
onChange={e => setResponseMsg(e.target.value)}
placeholder="Ваш ответ..."
className="border rounded-xl p-2 flex-1"
disabled={loading}
/>
<button
type="button"
onClick={() => respondTicket(ticket.id)}
className="bg-green-500 text-white px-4 py-2 rounded-xl hover:bg-green-600 transition"
disabled={loading || !(responseMsg && responseMsg.trim())}
>
{loading ? 'Отправка...' : 'Ответить'}
</button>
</>
)}
<button
type="button"
onClick={() => closeTicket(ticket.id)}
className="bg-red-500 text-white px-4 py-2 rounded-xl hover:bg-red-600 transition"
disabled={loading}
>
Закрыть тикет
</button>
</div>
)}
{ticket.status === 'closed' && (
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
)}
</div>
))}
</div>
</div>
);
};
export default Tickets;
export default TicketsPage;

View File

@@ -3,9 +3,26 @@ export interface User {
operator: number;
}
export interface Ticket {
id: number;
title: string;
message: string;
status: string;
createdAt: string;
responses: unknown[];
user?: { username: string };
}
export interface Server {
id: number;
name: string;
status: string;
// можно добавить другие поля по необходимости
}
export interface UserData {
user: User;
balance: number;
servers: unknown[];
tickets: unknown[];
servers: Server[];
tickets: Ticket[];
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import axios from 'axios';
import useAuth from '../context/useAuth';
@@ -9,6 +9,7 @@ const LoginPage = () => {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const handleLogin = async (e: React.FormEvent) => {
@@ -22,9 +23,13 @@ const LoginPage = () => {
password: password,
});
login(response.data.token);
// ИСПРАВЛЕНО: правильный путь к дашборду
navigate('/dashboard');
localStorage.setItem('token', response.data.token);
login(response.data.token);
// Возврат на исходную страницу, если был редирект
type LocationState = { from?: { pathname?: string } };
const state = location.state as LocationState | null;
const from = state?.from?.pathname || '/dashboard';
navigate(from);
} catch (err) {
if (axios.isAxiosError(err) && err.response) {

View File

@@ -1,130 +1,85 @@
import { useState } from 'react';
import { useState, useEffect, useContext } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import AuthContext from '../context/authcontext';
const TariffsPage = () => {
const [cpu, setCpu] = useState(1);
const [ram, setRam] = useState(1);
const [storage, setStorage] = useState(50);
const cpuPrice = 500;
const ramPrice = 300;
const storagePrice = 5;
const [tariffs, setTariffs] = useState<Array<{id:number;name:string;price:number;description?:string}>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const navigate = useNavigate();
const { isLoggedIn } = useContext(AuthContext);
const total = cpu * cpuPrice + ram * ramPrice + storage * storagePrice;
useEffect(() => {
const fetchTariffs = async () => {
try {
const res = await axios.get('http://localhost:5000/api/tariff');
console.log('Ответ API тарифов:', res.data);
if (Array.isArray(res.data)) {
setTariffs(res.data);
} else {
setError('Некорректный формат данных тарифов');
setTariffs([]);
}
} catch (err) {
console.error('Ошибка загрузки тарифов:', err);
setError('Ошибка загрузки тарифов');
setTariffs([]);
}
setLoading(false);
};
fetchTariffs();
}, []);
return (
<div className="min-h-screen bg-gray-50 py-20">
<div className="container mx-auto px-4">
<h1 className="text-4xl md:text-5xl font-bold text-center mb-16 text-gray-900">Выберите подходящий тариф</h1>
{/* Basic Tariffs Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
{/* Tariff Card 1 */}
<div className="bg-white p-8 rounded-2xl shadow-xl text-center transition-transform hover:scale-105 duration-300">
<h2 className="text-3xl font-bold text-gray-800">Базовый</h2>
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">1500<span className="text-lg font-normal text-gray-500">/мес</span></p>
<ul className="mt-4 text-gray-600 space-y-2">
<li>1 ядро CPU</li>
<li>2 ГБ RAM</li>
<li>100 ГБ SSD</li>
<li>Неограниченный трафик</li>
</ul>
<button className="mt-8 px-6 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
Выбрать
</button>
</div>
{/* Tariff Card 2 */}
<div className="bg-white p-8 rounded-2xl shadow-xl text-center border-4 border-ospab-primary transition-transform hover:scale-105 duration-300">
<h2 className="text-3xl font-bold text-gray-800">Профессиональный</h2>
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">4000<span className="text-lg font-normal text-gray-500">/мес</span></p>
<ul className="mt-4 text-gray-600 space-y-2">
<li>4 ядра CPU</li>
<li>8 ГБ RAM</li>
<li>250 ГБ SSD</li>
<li>Приоритетная поддержка</li>
</ul>
<button className="mt-8 px-6 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
Выбрать
</button>
</div>
{/* Tariff Card 3 */}
<div className="bg-white p-8 rounded-2xl shadow-xl text-center transition-transform hover:scale-105 duration-300">
<h2 className="text-3xl font-bold text-gray-800">Бизнес</h2>
<p className="mt-4 text-4xl font-extrabold text-ospab-primary">8000<span className="text-lg font-normal text-gray-500">/мес</span></p>
<ul className="mt-4 text-gray-600 space-y-2">
<li>8 ядер CPU</li>
<li>16 ГБ RAM</li>
<li>500 ГБ SSD</li>
<li>24/7 Мониторинг</li>
</ul>
<button className="mt-8 px-6 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
Выбрать
</button>
</div>
</div>
const handleBuy = (tariffId: number) => {
if (!isLoggedIn) {
navigate('/login');
return;
}
navigate(`/dashboard/checkout?tariff=${tariffId}`);
};
{/* Server Constructor Section */}
<div className="bg-white p-10 rounded-3xl shadow-2xl max-w-4xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-8 text-gray-900">Соберите свой сервер</h2>
<div className="space-y-6">
{/* CPU Slider */}
<div>
<label htmlFor="cpu" className="block text-lg font-medium text-gray-700">Ядра CPU: {cpu}</label>
<input
type="range"
id="cpu"
min="1"
max="16"
value={cpu}
onChange={(e) => setCpu(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<p className="text-sm text-gray-500 mt-1">Цена: {cpu * cpuPrice}</p>
</div>
{/* RAM Slider */}
<div>
<label htmlFor="ram" className="block text-lg font-medium text-gray-700">Оперативная память (ГБ): {ram}</label>
<input
type="range"
id="ram"
min="1"
max="32"
value={ram}
onChange={(e) => setRam(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<p className="text-sm text-gray-500 mt-1">Цена: {ram * ramPrice}</p>
</div>
{/* Storage Slider */}
<div>
<label htmlFor="storage" className="block text-lg font-medium text-gray-700">Диск (ГБ): {storage}</label>
<input
type="range"
id="storage"
min="50"
max="2000"
step="50"
value={storage}
onChange={(e) => setStorage(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
/>
<p className="text-sm text-gray-500 mt-1">Цена: {storage * storagePrice}</p>
</div>
</div>
<div className="mt-8 text-center">
<p className="text-2xl font-bold text-gray-800">Итого: {total}<span className="text-lg font-normal text-gray-500">/мес</span></p>
<button className="mt-4 px-8 py-4 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent">
Собрать сервер
</button>
</div>
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-gray-50 py-20">
<div className="container mx-auto px-4">
<h1 className="text-4xl md:text-5xl font-bold text-center mb-16 text-gray-900">Тарифы</h1>
{loading ? (
<p className="text-lg text-gray-500 text-center">Загрузка...</p>
) : error ? (
<p className="text-lg text-red-500 text-center">{error}</p>
) : tariffs.length === 0 ? (
<p className="text-lg text-gray-500 text-center">Нет доступных тарифов.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-20">
{tariffs.map(tariff => (
<div key={tariff.id} className="bg-white p-12 rounded-3xl shadow-2xl text-left flex flex-col justify-between transition-transform hover:scale-105 duration-300 min-h-[340px]">
<div>
<h2 className="text-4xl font-bold text-gray-800 mb-4">{tariff.name}</h2>
<p className="mb-4 text-5xl font-extrabold text-ospab-primary">{tariff.price}<span className="text-xl font-normal text-gray-500">/мес</span></p>
{tariff.description && (
<ul className="text-lg text-gray-700 mb-6 list-disc list-inside">
{tariff.description.split(',').map((desc, i) => (
<li key={i}>{desc.trim()}</li>
))}
</ul>
)}
</div>
<button
className="mt-4 px-8 py-4 rounded-full text-white font-bold text-xl transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent"
onClick={() => handleBuy(tariff.id)}
>
Купить
</button>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default TariffsPage;
export default TariffsPage;