From 11fb813a091d4d59c3b7437e3ab158fe9a4e0bad Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Mon, 20 Jul 2026 00:27:48 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B8=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/about/index.js | 32 + modules/activities/index.js | 55 ++ modules/auth/index.js | 124 ++++ modules/database/crud.js | 65 ++ modules/database/index.js | 301 ++++++++ modules/equipment/index.js | 116 +++ modules/geography/index.js | 32 + modules/hero/index.js | 33 + modules/leads/index.js | 45 ++ modules/licenses/index.js | 55 ++ modules/partners/index.js | 51 ++ modules/processSteps/index.js | 12 + modules/services/index.js | 12 + modules/settings/index.js | 29 + modules/siteData/index.js | 86 +++ modules/users/index.js | 84 +++ modules/workSchemes/index.js | 12 + package.json | 3 + public/admin.html | 989 +++++++++++++++++++++++++ public/index.html | 1286 +++++++++++++-------------------- server.js | 163 ++--- 21 files changed, 2714 insertions(+), 871 deletions(-) create mode 100644 modules/about/index.js create mode 100644 modules/activities/index.js create mode 100644 modules/auth/index.js create mode 100644 modules/database/crud.js create mode 100644 modules/database/index.js create mode 100644 modules/equipment/index.js create mode 100644 modules/geography/index.js create mode 100644 modules/hero/index.js create mode 100644 modules/leads/index.js create mode 100644 modules/licenses/index.js create mode 100644 modules/partners/index.js create mode 100644 modules/processSteps/index.js create mode 100644 modules/services/index.js create mode 100644 modules/settings/index.js create mode 100644 modules/siteData/index.js create mode 100644 modules/users/index.js create mode 100644 modules/workSchemes/index.js create mode 100644 public/admin.html diff --git a/modules/about/index.js b/modules/about/index.js new file mode 100644 index 0000000..123ad19 --- /dev/null +++ b/modules/about/index.js @@ -0,0 +1,32 @@ +const { createCrudRoutes } = require('../database/crud'); + +let db; +function init(database) { db = database; } + +function setupRoutes(app, auth) { + app.get('/api/admin/about-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => { + db.all(`SELECT * FROM about_settings ORDER BY id`, (err, rows) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + const obj = {}; + rows.forEach(r => { obj[r.key] = r.value; }); + res.json(obj); + }); + }); + + app.put('/api/admin/about-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const entries = req.body; + if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' }); + const stmt = db.prepare(`UPDATE about_settings SET value = ? WHERE key = ?`); + for (const [key, value] of Object.entries(entries)) { + stmt.run(value, key); + } + stmt.finalize(); + res.json({ message: 'Обновлено' }); + }); + + createCrudRoutes(app, auth, 'about_features', { + fields: ['svg_icon', 'title', 'description', 'sort_order'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/activities/index.js b/modules/activities/index.js new file mode 100644 index 0000000..b11e3a7 --- /dev/null +++ b/modules/activities/index.js @@ -0,0 +1,55 @@ +const { createCrudRoutes } = require('../database/crud'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const uploadDir = path.join(__dirname, '..', '..', 'data', 'activity_images'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + const storage = multer.diskStorage({ + destination: uploadDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, 'act_' + Date.now() + ext); + } + }); + const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp']; + cb(null, allowed.includes(path.extname(file.originalname).toLowerCase())); + } + }); + + app.post('/api/admin/activities/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); + let fname = req.file.filename; + const ext = path.extname(fname).toLowerCase(); + if (ext !== '.webp') { + try { + const webpName = path.basename(fname, ext) + '.webp'; + await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName)); + fs.unlinkSync(req.file.path); + fname = webpName; + } catch (e) { console.error(e); } + } + res.json({ path: fname, url: `/data/activity_images/${fname}` }); + }); + + createCrudRoutes(app, auth, 'public_activities', { + fields: ['svg_icon', 'title', 'description', 'sort_order'] + }); + + createCrudRoutes(app, auth, 'activity_gallery', { + fields: ['title', 'description', 'image_path', 'sort_order'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/auth/index.js b/modules/auth/index.js new file mode 100644 index 0000000..bf19cd6 --- /dev/null +++ b/modules/auth/index.js @@ -0,0 +1,124 @@ +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcryptjs'); + +let db; + +function init(database) { + db = database; +} + +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'Требуется авторизация' }); + } + jwt.verify(token, process.env.HOTEL777KEY, (err, user) => { + if (err) { + return res.status(403).json({ error: 'Недействительный токен' }); + } + req.user = user; + next(); + }); +} + +function requireAdmin(req, res, next) { + if (req.user && req.user.role === 'admin') { + return next(); + } + return res.status(403).json({ error: 'Требуются права администратора' }); +} + +function syncAdmin() { + return new Promise((resolve) => { + const login = process.env.ADMIN_USERNAME || 'admin'; + const password = process.env.ADMIN_PASSWORD || 'admin123'; + const hash = bcrypt.hashSync(password, 10); + + db.get(`SELECT id FROM users WHERE login = ?`, [login], (err, row) => { + if (row) { + db.run(`UPDATE users SET password_hash = ?, role = 'admin' WHERE login = ?`, [hash, login], function() { + resolve(); + }); + } else { + db.run(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, ?, 'admin')`, + [login, hash, 'Администратор'], function() { + resolve(); + }); + } + }); + }); +} + +function setupRoutes(app) { + app.post('/api/auth/login', (req, res) => { + const { login, password } = req.body; + if (!login || !password) { + return res.status(400).json({ error: 'Логин и пароль обязательны' }); + } + + db.get(`SELECT * FROM users WHERE login = ?`, [login], (err, user) => { + if (err || !user) { + return res.status(401).json({ error: 'Неверный логин или пароль' }); + } + + const valid = bcrypt.compareSync(password, user.password_hash); + if (!valid) { + return res.status(401).json({ error: 'Неверный логин или пароль' }); + } + + const token = jwt.sign( + { id: user.id, login: user.login, role: user.role }, + process.env.HOTEL777KEY, + { expiresIn: '24h' } + ); + + res.json({ + token, + user: { + id: user.id, + login: user.login, + full_name: user.full_name, + role: user.role + } + }); + }); + }); + + app.get('/api/auth/me', authenticateToken, (req, res) => { + db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [req.user.id], (err, user) => { + if (err || !user) { + return res.status(404).json({ error: 'Пользователь не найден' }); + } + res.json(user); + }); + }); + + app.put('/api/auth/me', authenticateToken, (req, res) => { + const { full_name } = req.body; + db.run(`UPDATE users SET full_name = ? WHERE id = ?`, [full_name || '', req.user.id], function(err) { + if (err) return res.status(500).json({ error: 'Ошибка обновления' }); + res.json({ message: 'Профиль обновлён' }); + }); + }); + + app.post('/api/auth/change-password', authenticateToken, (req, res) => { + const { old_password, new_password } = req.body; + if (!old_password || !new_password) { + return res.status(400).json({ error: 'Старый и новый пароль обязательны' }); + } + db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, user) => { + if (err || !user) return res.status(404).json({ error: 'Пользователь не найден' }); + if (!bcrypt.compareSync(old_password, user.password_hash)) { + return res.status(400).json({ error: 'Неверный старый пароль' }); + } + const hash = bcrypt.hashSync(new_password, 10); + db.run(`UPDATE users SET password_hash = ? WHERE id = ?`, [hash, req.user.id], (err2) => { + if (err2) return res.status(500).json({ error: 'Ошибка обновления пароля' }); + res.json({ message: 'Пароль изменён' }); + }); + }); + }); +} + +module.exports = { init, authenticateToken, requireAdmin, syncAdmin, setupRoutes }; diff --git a/modules/database/crud.js b/modules/database/crud.js new file mode 100644 index 0000000..00769bb --- /dev/null +++ b/modules/database/crud.js @@ -0,0 +1,65 @@ +function createCrudRoutes(app, auth, table, options = {}) { + const { authenticateToken, requireAdmin } = auth; + const prefix = options.prefix || `/api/admin/${table}`; + const allowedFields = options.fields || []; + const extraRoutes = options.extraRoutes || null; + + app.get(prefix, authenticateToken, requireAdmin, (req, res) => { + const orderBy = options.orderBy || 'sort_order'; + db.all(`SELECT * FROM ${table} ORDER BY ${orderBy}`, (err, rows) => { + if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка БД' }); } + res.json(rows); + }); + }); + + app.post(prefix, authenticateToken, requireAdmin, (req, res) => { + const values = allowedFields.map(f => req.body[f] !== undefined ? req.body[f] : null); + const placeholders = values.map(() => '?').join(', '); + const cols = allowedFields.join(', '); + const stmt = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`); + stmt.run(...values, function(err) { + if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка создания' }); } + db.get(`SELECT * FROM ${table} WHERE id = ?`, [this.lastID], (err2, row) => { + if (err2) return res.status(500).json({ error: 'Ошибка получения' }); + res.status(201).json(row); + }); + }); + stmt.finalize(); + }); + + app.put(`${prefix}/:id`, authenticateToken, requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + const setClauses = allowedFields.map(f => `${f} = ?`).join(', '); + const values = allowedFields.map(f => req.body[f] !== undefined ? req.body[f] : null); + values.push(id); + const stmt = db.prepare(`UPDATE ${table} SET ${setClauses} WHERE id = ?`); + stmt.run(...values, function(err) { + if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка обновления' }); } + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + db.get(`SELECT * FROM ${table} WHERE id = ?`, [id], (err2, row) => { + if (err2) return res.status(500).json({ error: 'Ошибка получения' }); + res.json(row); + }); + }); + stmt.finalize(); + }); + + app.delete(`${prefix}/:id`, authenticateToken, requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + const stmt = db.prepare(`DELETE FROM ${table} WHERE id = ?`); + stmt.run(id, function(err) { + if (err) { console.error(err); return res.status(500).json({ error: 'Ошибка удаления' }); } + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + res.json({ message: 'Удалено' }); + }); + stmt.finalize(); + }); + + if (extraRoutes) extraRoutes(app, authenticateToken, requireAdmin); +} + +let db; +function init(database) { db = database; } +module.exports = { createCrudRoutes, init }; diff --git a/modules/database/index.js b/modules/database/index.js new file mode 100644 index 0000000..459c2ab --- /dev/null +++ b/modules/database/index.js @@ -0,0 +1,301 @@ +let db; + +function init(database) { + db = database; + return initDatabase(); +} + +function initDatabase() { + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run(`CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT NOT NULL, + message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + db.run(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + full_name TEXT, + role TEXT NOT NULL DEFAULT 'admin', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + db.run(`CREATE TABLE IF NOT EXISTS hero_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT + )`); + db.run(`CREATE TABLE IF NOT EXISTS hero_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + value TEXT NOT NULL, + is_dynamic INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS about_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT + )`); + db.run(`CREATE TABLE IF NOT EXISTS about_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + svg_icon TEXT, + title TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + svg_icon TEXT, + title TEXT NOT NULL, + description TEXT, + items TEXT DEFAULT '[]', + is_large INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1 + )`); + db.run(`CREATE TABLE IF NOT EXISTS work_schemes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + svg_icon TEXT, + title TEXT NOT NULL, + description TEXT, + items TEXT DEFAULT '[]', + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1 + )`); + db.run(`CREATE TABLE IF NOT EXISTS process_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + step_number INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS equipment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + brand TEXT, + image_path TEXT, + qty TEXT, + specs TEXT DEFAULT '[]', + age_badge TEXT DEFAULT '', + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1 + )`); + db.run(`CREATE TABLE IF NOT EXISTS equipment_gallery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + equipment_id INTEGER NOT NULL, + image_path TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (equipment_id) REFERENCES equipment(id) ON DELETE CASCADE + )`); + db.run(`CREATE TABLE IF NOT EXISTS geo_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS geo_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT + )`); + db.run(`CREATE TABLE IF NOT EXISTS licenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + svg_icon TEXT, + title TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS license_gallery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + image_path TEXT, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS public_activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + svg_icon TEXT, + title TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS activity_gallery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + image_path TEXT, + sort_order INTEGER DEFAULT 0 + )`); + db.run(`CREATE TABLE IF NOT EXISTS partners ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + logo_path TEXT, + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1 + )`); + db.run(`CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT + )`); + + db.get(`SELECT COUNT(*) as cnt FROM settings`, (err, row) => { + if (row && row.cnt > 0) { resolve(); return; } + seedAll().then(resolve).catch(reject); + }); + }); + }); +} + +async function seedAll() { + const run = (sql, params) => new Promise(r => db.run(sql, params || [], () => r())); + const runEach = (sql, rows) => Promise.all(rows.map(r => { + const stmt = db.prepare(sql); + return new Promise(res => { + stmt.run(...r, function() { stmt.finalize(); res(); }); + }); + })); + + const heroDefaults = [ + ['hero_bg', './img/hero-bg.webp'], ['badge_text', 'Лицензии СРО и НОСТРОЙ'], + ['h1_text', 'демонтаж нефтегазопроводов по России, утилизация отходов'], + ['subtitle', 'Собственная спецтехника. Лицензии СРО и НОСТРОЙ. Более {years} лет опыта. Работаем на опасных производственных объектах без привлечения субподрядчиков.'], + ['phone_800', '8 (800) 555-29-31'], ['phone_ekb', '8 (343) 237-25-66'], + ['email', 'sts_zakaz@mail.ru'], ['address', 'г. Екатеринбург, ул. Московская, 11, оф. 702'], + ['button1_text', 'Оставить заявку'], ['button1_link', '#contacts'], + ['button2_text', 'Наши услуги'], ['button2_link', '#services'] + ]; + await runEach('INSERT OR IGNORE INTO hero_settings (key, value) VALUES (?, ?)', heroDefaults); + + await runEach('INSERT INTO hero_stats (label, value, is_dynamic, sort_order) VALUES (?, ?, ?, ?)', [ + ['лет на рынке', '{years}', 1, 0], ['объектов выполнено', '200', 0, 1], + ['единиц техники', '80', 0, 2], ['федеральных округов', '6', 0, 3] + ]); + + await runEach('INSERT OR IGNORE INTO about_settings (key, value) VALUES (?, ?)', [ + ['section_subtitle', 'Компания «СТС-Авто» — надёжный партнёр в сфере демонтажа, оказывает услуги по демонтажу магистральных трубопроводов, объектов инфраструктуры, зданий, сооружений и разделки жд транспорта, с последующей переработкой и утилизацией для предприятий. Мы работаем на всей территории Российской Федерации.'], + ['main_heading', 'Собственная техника и полный контроль'], ['about_image', './img/hero-bg.webp'], + ['paragraph_1', 'Наш автопарк состоит из более чем 80 единиц современной спецтехники, возраст которой не превышает 5 лет. Мы используем экскаваторы, краны, погрузчики, самосвалы и тралы ведущих мировых производителей: KAMAZ, Komatsu, Zoomlion, SDLG, Соболь, УАЗ.'], + ['paragraph_2', 'Все работы выполняются исключительно силами компании — без привлечения субподрядчиков. Это гарантирует полное соответствие качества, сроков и ответственности на каждом этапе проекта.'], + ['paragraph_3', 'Мы работаем в самых сложных условиях: труднодоступные районы, заболоченные территории, Крайний Север. Гибкие варианты оплаты: работаем по договору, включая вариант расчёта трубой за услуги (бартер с металлоломом после демонтажа).'] + ]); + + await runEach('INSERT INTO about_features (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [ + ['', 'Парк техники не старше 5 лет', 'Более 80 единиц современной спецтехники', 0], + ['', 'Без субподрядчиков', 'Все работы силами компании', 1], + ['', 'Сложные условия', 'Болота, Крайний Север, труднодоступные районы', 2], + ['', 'Гибкая оплата', 'Включая расчёт трубой (бартер)', 3] + ]); + + const svcIcon = { + demolish: '', + recult: '', + clear: '', + lezh: '', + scheme1: '', + scheme2: '' + }; + + await runEach('INSERT INTO services (svg_icon, title, description, items, is_large, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [ + [svcIcon.demolish, 'Демонтаж нефтегазопроводов, утилизация отходов', 'Выполняем полный цикл работ по демонтажу магистральных и промысловых трубопроводов любого типа и диаметра, а также утилизацию отходов.', '["Нефтепроводы, газопроводы, водоводы — все типы магистральных и промысловых сетей","Полный цикл: от вскрытия и вырезки дефектных участков до вывоза и сдачи металлолома","Работа с трубопроводами любого диаметра в любых климатических условиях","Разделка ж/д транспорта, переработка и утилизация для предприятий"]', 1, 0], + [svcIcon.recult, 'Техническая рекультивация земель', 'Восстановление нарушенных земель после проведения строительных и ремонтных работ.', '["Засыпка траншей, планировка, восстановление плодородного слоя","Работа с нефтезагрязнёнными, засоленными землями","Сдача по актам под контролем экологов"]', 0, 1], + [svcIcon.clear, 'Расчистка территории от мелколесья и ДКР', 'Подготовка полосы отвода: расчистка от древесно-кустарниковой растительности.', '["Валка, корчёвка, дробление пней","Планировка полосы отвода","Мульчеры и кусторезы на гусеничном ходу"]', 0, 2], + [svcIcon.lezh, 'Устройство лежневого настила', 'Обеспечение проходимости для тяжёлой техники по болотам и слабым грунтам.', '["Изготовление секций из бревен и бруса","Укладка перекрытий на слабых грунтах","Обеспечение проходимости для тяжёлой гусеничной техники"]', 0, 3] + ]); + + await runEach('INSERT INTO work_schemes (svg_icon, title, description, items, sort_order) VALUES (?, ?, ?, ?, ?)', [ + [svcIcon.scheme1, 'Схема 1 — с доставкой заказчику', 'Подходит для компаний, планирующих повторное использование труб или их последующую реализацию. Расходный договор для заказчика.', '["Полный цикл работ по демонтажу трубопровода","Резка и маркировка труб в соответствии с требованиями","Транспортировка демонтированных труб на указанные территории хранения","Оформление всей сопроводительной документации (акты приёма-передачи)"]', 0], + [svcIcon.scheme2, 'Схема 2 — вывоз на базы подрядчика', 'Экономически выгодное решение для заказчиков, заинтересованных в минимизации затрат. Доходный договор для заказчика.', '["Полный цикл работ по демонтажу трубопровода","Перевозка и размещение на сертифицированных площадках временного хранения ООО «СТС-АВТО»","Покупка трубы в земле — заказчик получает доход от металлолома","Оформление полного пакета закрывающей документации"]', 1] + ]); + + await runEach('INSERT INTO process_steps (step_number, title, description, sort_order) VALUES (?, ?, ?, ?)', [ + [1, 'Заявка', 'Вы оставляете заявку на сайте, по электронной почте или звоните нам. Наши специалисты оперативно свяжутся с вами для обсуждения деталей проекта, объёма работ и сроков.', 0], + [2, 'Выезд специалиста и оценка', 'Наш инженер выезжает на объект для проведения осмотра, оценки объёма работ, определения необходимых ресурсов и техники. По результатам составляется техническое задание и подписывается договор.', 1], + [3, 'Выполнение работ', 'Мобилизуем технику и персонал на объект. Выполняем демонтаж, утилизацию отходов и рекультивацию в соответствии с проектной документацией. Ежедневные отчёты о ходе работ.', 2], + [4, 'Сдача объекта', 'По завершении всех работ подписывается акт выполненных работ, передаётся полный комплект закрывающей документации. При необходимости — сопровождение сдачи объекта экспертизе.', 3] + ]); + + await runEach('INSERT INTO equipment (name, brand, image_path, qty, specs, age_badge, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', [ + ['Автокран КАМАЗ КС-55717К-3', 'КАМАЗ', 'ks-45721.webp', '10 единиц', '[{"label":"Грузоподъёмность","value":"25 тонн"},{"label":"Вылет стрелы","value":"до 28 м"},{"label":"Шасси","value":"Полноприводное 6×6"}]', 'Не старше 5 лет', 0], + ['Автокран Zoomlion ZTC300', 'Zoomlion', 'zoomlion-ztc300.webp', '2 единицы', '[{"label":"Грузоподъёмность","value":"30 тонн"},{"label":"Вылет стрелы","value":"до 32 м"},{"label":"Стрела","value":"Телескопическая"}]', 'Не старше 5 лет', 1], + ['Экскаватор гусеничный Komatsu PC210-10M0', 'Komatsu', 'komatsu-pc210.webp', '20 единиц', '[{"label":"Масса","value":"21 тонна"},{"label":"Объём ковша","value":"1.0–1.2 м³"},{"label":"Глубина копания","value":"до 6.5 м"}]', 'Не старше 5 лет', 2], + ['Экскаватор гусеничный Zoomlion ZE215E', 'Zoomlion', 'zoomlion-ze215e.webp', '2 единицы', '[{"label":"Масса","value":"21.5 тонны"},{"label":"Объём ковша","value":"1.1 м³"},{"label":"Аналог","value":"Komatsu PC210"}]', 'Не старше 5 лет', 3], + ['Погрузчик колёсный SDLG LG933L', 'SDLG', 'sdlg-lg933l.webp', '2 единицы', '[{"label":"Грузоподъёмность","value":"3 тонны"},{"label":"Объём ковша","value":"1.7 м³"},{"label":"Тип","value":"Колёсный"}]', 'Не старше 5 лет', 4], + ['Соболь 4×4 грузо-пассажирский', 'Соболь', 'sobol-4x4.webp', '13 единиц', '[{"label":"Грузоподъёмность","value":"до 1 т"},{"label":"Привод","value":"Полный 4×4"},{"label":"Назначение","value":"Доставка персонала и запчастей"}]', 'Не старше 5 лет', 5], + ['Ломовоз с КМУ (кран-манипулятор)', 'Ломовоз', 'lomovoz-kmu.webp', '3 единицы', '[{"label":"Грузоподъёмность КМУ","value":"до 8 т"},{"label":"Объём платформы","value":"до 20 м³"},{"label":"Назначение","value":"Вывоз металлолома"}]', 'Не старше 5 лет', 6], + ['Седельный тягач Камаз 53504-50', 'КамАЗ', 'kamaz-53504.webp', '6 единиц', '[{"label":"Тяговый класс","value":"до 50 т"},{"label":"Привод","value":"Полный привод"},{"label":"Назначение","value":"Перевозка негабарита и тралов"}]', 'Не старше 5 лет', 7], + ['Экскаватор-амфибия на понтонной платформе', 'Амфибия', 'amfibia.webp', '4 единицы', '[{"label":"Тип","value":"Гусеничный, плавающий"},{"label":"Работает","value":"На болотах и водных преградах"},{"label":"Особенность","value":"Там, где не видно дна"}]', 'Не старше 5 лет', 8], + ['УАЗ Пикап (полный привод)', 'УАЗ', 'uaz-pickup.webp', 'Несколько единиц', '[{"label":"Привод","value":"Полный привод"},{"label":"Назначение","value":"Доставка запчастей на месторождения"}]', 'Не старше 5 лет', 9], + ['Тралы тяжеловозы для доставки спецтехники', 'Тралы', 'tral-1.webp', '3 единицы', '[{"label":"Тяговый класс","value":"до 50 т"},{"label":"Назначение","value":"Доставка спецтехники на объекты"}]', 'Не старше 5 лет', 10] + ]); + + await runEach('INSERT INTO geo_regions (name, sort_order) VALUES (?, ?)', [ + ['УрФО', 0], ['СФО', 1], ['ПФО', 2], ['СЗФО', 3], ['ДФО', 4], ['ЮФО', 5] + ]); + + await runEach('INSERT OR IGNORE INTO geo_settings (key, value) VALUES (?, ?)', [ + ['section_subtitle', 'Работаем во всех федеральных округах Российской Федерации. Особые условия для труднодоступных районов Крайнего Севера и заболоченных территорий.'], + ['difficult_title', '🔧 Труднодоступные районы'], + ['difficult_desc', 'Мы располагаем специализированной техникой для работы в болотах ХМАО-Югры, ЯНАО, на севере Красноярского края, в Иркутской области. Гусеничная техника и лежневые настилы обеспечивают проходимость даже в самых сложных условиях.'], + ['map_image', './img/geo.webp'] + ]); + + const licIcon = ''; + await runEach('INSERT INTO licenses (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [ + [licIcon, 'СРО — допуск к работам на опасных объектах', 'Свидетельство о допуске к работам на опасных производственных объектах нефтегазового комплекса.', 0], + [licIcon, 'Членство в НОСТРОЙ', 'Действующее членство в Национальном объединении строителей — допуск к строительным работам.', 1], + ['', 'Сертификат ISO 9001 (качество)', 'Международный стандарт системы менеджмента качества.', 2], + ['', 'Сертификат OHSAS 18001 (охрана труда)', 'Система менеджмента охраны труда и промышленной безопасности.', 3], + ['', 'Договор ДМС для сотрудников', 'Добровольное медицинское страхование всех сотрудников.', 4], + ['', 'Лицензия на заготовку и переработку лома', 'Лицензия на заготовку, хранение и переработку лома черных металлов.', 5] + ]); + + await runEach('INSERT INTO license_gallery (title, description, image_path, sort_order) VALUES (?, ?, ?, ?)', [ + ['Лицензия на отходы I–IV класса опасности', '№ (66) -6527 - СТО/П от 21 мая 2019 г.', 'license-otxody.webp', 0], + ['Лицензия на лом черных и цветных металлов', '№ ЛМ 000092 от 31 октября 2018 г.', 'license-lom.webp', 1] + ]); + + await runEach('INSERT INTO public_activities (svg_icon, title, description, sort_order) VALUES (?, ?, ?, ?)', [ + ['', 'Спонсорство', 'Поддерживаем спортивные, культурные и общественные мероприятия в регионах присутствия. Помогаем развитию местных сообществ.', 0], + ['', 'Благотворительность', 'Оказываем адресную помощь детским домам, ветеранским и общественным организациям. Участвуем в социальных проектах.', 1], + ['', 'Экология', 'Участвуем в экологических акциях, субботниках и проектах по озеленению. Следим за снижением воздействия на окружающую среду.', 2], + ['', 'Поддержка образования', 'Сотрудничаем с профильными вузами и колледжами. Организуем практику для студентов, участвуем в ярмарках вакансий.', 3] + ]); + + await runEach('INSERT INTO activity_gallery (title, description, image_path, sort_order) VALUES (?, ?, ?, ?)', [ + ['Благодарственное письмо', 'За вклад в развитие региона', 'thanks-1.webp', 0], + ['Благодарственное письмо', 'За благотворительную деятельность', 'thanks-2.webp', 1] + ]); + + await runEach('INSERT INTO partners (name, logo_path, sort_order) VALUES (?, ?, ?)', [ + ['Газпром', 'газпром.webp', 0], ['Роснефть', 'роснефть.webp', 1], + ['Сибур', 'сибур.webp', 2], ['Сургутнефтегаз', 'сургутнефтегаз.webp', 3], + ['Транснефть', 'транснефть.webp', 4] + ]); + + await runEach('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', [ + ['contact_phone_800', '8 (800) 555-29-31'], ['contact_phone_ekb', '8 (343) 237-25-66'], + ['contact_email', 'sts_zakaz@mail.ru'], ['contact_address', 'г. Екатеринбург, ул. Московская, 11, оф. 702'], + ['contact_work_hours', 'Пн–Пт: 9:00 – 18:00|Сб: 10:00 – 15:00'], ['contact_map_image', './img/secret.webp'], + ['footer_text', '© {year} ООО «СТС-Авто». Все права защищены. 16+'], + ['form_title', 'Оставить заявку'], ['form_subtitle', 'Заполните форму и мы свяжемся с вами в ближайшее время'], + ['section_services_subtitle', 'Полный спектр работ по демонтажу и утилизации трубопроводной инфраструктуры. Работаем на опасных производственных объектах с собственным парком спецтехники.'], + ['section_schemes_subtitle', 'Мы разработали гибкие схемы работы, позволяющие выбрать оптимальное решение с учётом ваших целей и бюджета.'], + ['section_process_subtitle', 'Прозрачный и отлаженный процесс от первого обращения до сдачи объекта. Каждый этап контролируется инженерами компании.'], + ['section_equipment_subtitle', 'Собственный парк из более чем 80 единиц современной спецтехники ведущих мировых производителей. Возраст всей техники — не старше 5 лет.'], + ['section_licenses_subtitle', 'Все необходимые допуски и сертификаты для выполнения работ на опасных производственных объектах. Полная прозрачность и соответствие требованиям законодательства РФ.'], + ['section_activity_subtitle', 'Компания «СТС-Авто» активно участвует в социальной и общественной жизни регионов присутствия.'], + ['section_partners_subtitle', 'Сотрудничаем с ведущими компаниями нефтегазового сектора.'], + ['section_contacts_subtitle', 'Оставьте заявку или позвоните — мы оперативно ответим на ваши вопросы и рассчитаем стоимость работ.'] + ]); +} + +function getDb() { + return db; +} + +module.exports = { init, getDb }; diff --git a/modules/equipment/index.js b/modules/equipment/index.js new file mode 100644 index 0000000..a566927 --- /dev/null +++ b/modules/equipment/index.js @@ -0,0 +1,116 @@ +const { createCrudRoutes } = require('../database/crud'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const uploadDir = path.join(__dirname, '..', '..', 'data', 'equipment_images'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + const storage = multer.diskStorage({ + destination: uploadDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, 'equip_' + Date.now() + '_' + Math.round(Math.random() * 1000) + ext); + } + }); + const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, allowed.includes(ext)); + } + }); + + async function convertToWebp(filePath, fname) { + const ext = path.extname(fname).toLowerCase(); + if (ext === '.webp') return fname; + try { + const webpName = path.basename(fname, ext) + '.webp'; + await sharp(filePath).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName)); + fs.unlinkSync(filePath); + return webpName; + } catch (e) { return fname; } + } + + app.post('/api/admin/equipment/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); + const fname = await convertToWebp(req.file.path, req.file.filename); + res.json({ path: fname, url: `/data/equipment_images/${fname}` }); + }); + + app.post('/api/admin/equipment/gallery/upload', auth.authenticateToken, auth.requireAdmin, upload.array('images', 10), async (req, res) => { + if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'Файлы не загружены' }); + const results = []; + for (const file of req.files) { + const fname = await convertToWebp(file.path, file.filename); + results.push({ path: fname, url: `/data/equipment_images/${fname}` }); + } + res.json(results); + }); + + app.get('/api/admin/equipment/:id/gallery', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + db.all(`SELECT * FROM equipment_gallery WHERE equipment_id = ? ORDER BY sort_order`, [id], (err, rows) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + res.json(rows); + }); + }); + + app.post('/api/admin/equipment/:id/gallery', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + const { image_path, sort_order } = req.body; + if (!image_path) return res.status(400).json({ error: 'Не указан путь к изображению' }); + const stmt = db.prepare(`INSERT INTO equipment_gallery (equipment_id, image_path, sort_order) VALUES (?, ?, ?)`); + stmt.run(id, image_path, sort_order || 0, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка создания' }); + db.get(`SELECT * FROM equipment_gallery WHERE id = ?`, [this.lastID], (e2, row) => { + if (e2) return res.status(500).json({}); + res.status(201).json(row); + }); + }); + stmt.finalize(); + }); + + app.put('/api/admin/equipment/gallery/:imageId', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const imageId = parseInt(req.params.imageId); + if (isNaN(imageId)) return res.status(400).json({ error: 'Неверный ID' }); + const { image_path, sort_order } = req.body; + const stmt = db.prepare(`UPDATE equipment_gallery SET image_path = COALESCE(?, image_path), sort_order = COALESCE(?, sort_order) WHERE id = ?`); + stmt.run(image_path || null, sort_order != null ? sort_order : null, imageId, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка обновления' }); + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + db.get(`SELECT * FROM equipment_gallery WHERE id = ?`, [imageId], (e2, row) => { + res.json(row); + }); + }); + stmt.finalize(); + }); + + app.delete('/api/admin/equipment/gallery/:imageId', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const imageId = parseInt(req.params.imageId); + if (isNaN(imageId)) return res.status(400).json({ error: 'Неверный ID' }); + const stmt = db.prepare(`DELETE FROM equipment_gallery WHERE id = ?`); + stmt.run(imageId, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка удаления' }); + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + res.json({ message: 'Удалено' }); + }); + stmt.finalize(); + }); + + createCrudRoutes(app, auth, 'equipment', { + fields: ['name', 'brand', 'image_path', 'qty', 'specs', 'age_badge', 'sort_order', 'is_active'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/geography/index.js b/modules/geography/index.js new file mode 100644 index 0000000..f30aaf8 --- /dev/null +++ b/modules/geography/index.js @@ -0,0 +1,32 @@ +const { createCrudRoutes } = require('../database/crud'); + +let db; +function init(database) { db = database; } + +function setupRoutes(app, auth) { + app.get('/api/admin/geo-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => { + db.all(`SELECT * FROM geo_settings ORDER BY id`, (err, rows) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + const obj = {}; + rows.forEach(r => { obj[r.key] = r.value; }); + res.json(obj); + }); + }); + + app.put('/api/admin/geo-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const entries = req.body; + if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' }); + const stmt = db.prepare(`UPDATE geo_settings SET value = ? WHERE key = ?`); + for (const [key, value] of Object.entries(entries)) { + stmt.run(value, key); + } + stmt.finalize(); + res.json({ message: 'Обновлено' }); + }); + + createCrudRoutes(app, auth, 'geo_regions', { + fields: ['name', 'sort_order'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/hero/index.js b/modules/hero/index.js new file mode 100644 index 0000000..8396d90 --- /dev/null +++ b/modules/hero/index.js @@ -0,0 +1,33 @@ +const { createCrudRoutes } = require('../database/crud'); + +let db; +function init(database) { db = database; } + +function setupRoutes(app, auth) { + app.get('/api/admin/hero-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => { + db.all(`SELECT * FROM hero_settings ORDER BY id`, (err, rows) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + const obj = {}; + rows.forEach(r => { obj[r.key] = r.value; }); + res.json(obj); + }); + }); + + app.put('/api/admin/hero-settings', auth.authenticateToken, auth.requireAdmin, (req, res) => { + const entries = req.body; + if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' }); + const stmt = db.prepare(`UPDATE hero_settings SET value = ? WHERE key = ?`); + let count = 0; + for (const [key, value] of Object.entries(entries)) { + stmt.run(value, key, function() { count++; }); + } + stmt.finalize(); + res.json({ message: 'Обновлено', updated: count }); + }); + + createCrudRoutes(app, auth, 'hero_stats', { + fields: ['label', 'value', 'is_dynamic', 'sort_order'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/leads/index.js b/modules/leads/index.js new file mode 100644 index 0000000..bf7f1fc --- /dev/null +++ b/modules/leads/index.js @@ -0,0 +1,45 @@ +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const { authenticateToken, requireAdmin } = auth; + + app.get('/api/admin/leads', authenticateToken, requireAdmin, (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = 20; + const offset = (page - 1) * limit; + const search = req.query.search || ''; + + let where = ''; + const params = []; + if (search) { + where = `WHERE name LIKE ? OR phone LIKE ? OR message LIKE ?`; + params.push(`%${search}%`, `%${search}%`, `%${search}%`); + } + + db.get(`SELECT COUNT(*) as total FROM leads ${where}`, params, (err, countRow) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + const total = countRow.total; + db.all(`SELECT * FROM leads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, + [...params, limit, offset], (err2, rows) => { + if (err2) return res.status(500).json({ error: 'Ошибка БД' }); + res.json({ data: rows, total, page, totalPages: Math.ceil(total / limit) }); + }); + }); + }); + + app.delete('/api/admin/leads/:id', authenticateToken, requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + const stmt = db.prepare(`DELETE FROM leads WHERE id = ?`); + stmt.run(id, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка удаления' }); + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + res.json({ message: 'Заявка удалена' }); + }); + stmt.finalize(); + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/licenses/index.js b/modules/licenses/index.js new file mode 100644 index 0000000..3450a01 --- /dev/null +++ b/modules/licenses/index.js @@ -0,0 +1,55 @@ +const { createCrudRoutes } = require('../database/crud'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const uploadDir = path.join(__dirname, '..', '..', 'data', 'license_images'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + const storage = multer.diskStorage({ + destination: uploadDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, 'lic_' + Date.now() + ext); + } + }); + const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp']; + cb(null, allowed.includes(path.extname(file.originalname).toLowerCase())); + } + }); + + app.post('/api/admin/licenses/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); + let fname = req.file.filename; + const ext = path.extname(fname).toLowerCase(); + if (ext !== '.webp') { + try { + const webpName = path.basename(fname, ext) + '.webp'; + await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName)); + fs.unlinkSync(req.file.path); + fname = webpName; + } catch (e) { console.error(e); } + } + res.json({ path: fname, url: `/data/license_images/${fname}` }); + }); + + createCrudRoutes(app, auth, 'licenses', { + fields: ['svg_icon', 'title', 'description', 'sort_order'] + }); + + createCrudRoutes(app, auth, 'license_gallery', { + fields: ['title', 'description', 'image_path', 'sort_order'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/partners/index.js b/modules/partners/index.js new file mode 100644 index 0000000..1f1cf5f --- /dev/null +++ b/modules/partners/index.js @@ -0,0 +1,51 @@ +const { createCrudRoutes } = require('../database/crud'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const uploadDir = path.join(__dirname, '..', '..', 'data', 'partner_images'); + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); + + const storage = multer.diskStorage({ + destination: uploadDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, 'prt_' + Date.now() + ext); + } + }); + const upload = multer({ + storage, + limits: { fileSize: 3 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.svg']; + cb(null, allowed.includes(path.extname(file.originalname).toLowerCase())); + } + }); + + app.post('/api/admin/partners/upload', auth.authenticateToken, auth.requireAdmin, upload.single('image'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); + let fname = req.file.filename; + const ext = path.extname(fname).toLowerCase(); + if (ext !== '.webp' && ext !== '.svg') { + try { + const webpName = path.basename(fname, ext) + '.webp'; + await sharp(req.file.path).webp({ quality: 85 }).toFile(path.join(uploadDir, webpName)); + fs.unlinkSync(req.file.path); + fname = webpName; + } catch (e) { console.error(e); } + } + res.json({ path: fname, url: `/data/partner_images/${fname}` }); + }); + + createCrudRoutes(app, auth, 'partners', { + fields: ['name', 'logo_path', 'sort_order', 'is_active'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/processSteps/index.js b/modules/processSteps/index.js new file mode 100644 index 0000000..90d9c38 --- /dev/null +++ b/modules/processSteps/index.js @@ -0,0 +1,12 @@ +const { createCrudRoutes } = require('../database/crud'); + +let db; +function init(database) { db = database; } + +function setupRoutes(app, auth) { + createCrudRoutes(app, auth, 'process_steps', { + fields: ['step_number', 'title', 'description', 'sort_order'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/services/index.js b/modules/services/index.js new file mode 100644 index 0000000..ab1b086 --- /dev/null +++ b/modules/services/index.js @@ -0,0 +1,12 @@ +const { createCrudRoutes } = require('../database/crud'); + +let db; +function init(database) { db = database; } + +function setupRoutes(app, auth) { + createCrudRoutes(app, auth, 'services', { + fields: ['svg_icon', 'title', 'description', 'items', 'is_large', 'sort_order', 'is_active'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/settings/index.js b/modules/settings/index.js new file mode 100644 index 0000000..9a826c4 --- /dev/null +++ b/modules/settings/index.js @@ -0,0 +1,29 @@ +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const { authenticateToken, requireAdmin } = auth; + + app.get('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => { + db.all(`SELECT * FROM settings ORDER BY id`, (err, rows) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + const obj = {}; + rows.forEach(r => { obj[r.key] = r.value; }); + res.json(obj); + }); + }); + + app.put('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => { + const entries = req.body; + if (!entries || typeof entries !== 'object') return res.status(400).json({ error: 'Неверные данные' }); + const stmt = db.prepare(`UPDATE settings SET value = ? WHERE key = ?`); + for (const [key, value] of Object.entries(entries)) { + stmt.run(String(value), key); + } + stmt.finalize(); + res.json({ message: 'Обновлено' }); + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/siteData/index.js b/modules/siteData/index.js new file mode 100644 index 0000000..174031b --- /dev/null +++ b/modules/siteData/index.js @@ -0,0 +1,86 @@ +let db; + +function init(database) { db = database; } + +function loadEquipmentGallery(data) { + return new Promise((resolve) => { + db.all(`SELECT * FROM equipment_gallery ORDER BY equipment_id, sort_order`, (err, rows) => { + if (err || !rows) { data.equipment_gallery = []; resolve(); return; } + data.equipment_gallery = rows; + resolve(); + }); + }); +} + +function setupRoutes(app) { + app.get('/api/site-data', (req, res) => { + const data = {}; + + function loadTable(table, key) { + return new Promise((resolve) => { + db.all(`SELECT * FROM ${table} ORDER BY sort_order`, (err, rows) => { + if (err) { data[key] = []; resolve(); return; } + if (table === 'equipment' || table === 'partners') { + rows = rows.filter(r => r.is_active == 1); + } + if (table === 'services' || table === 'work_schemes') { + rows = rows.filter(r => r.is_active == 1); + } + data[key] = rows; + resolve(); + }); + }); + } + + function loadSettings(table, key, transformFn) { + return new Promise((resolve) => { + db.all(`SELECT * FROM ${table} ORDER BY id`, (err, rows) => { + if (err) { data[key] = {}; resolve(); return; } + if (transformFn) { + data[key] = transformFn(rows); + } else { + const obj = {}; + rows.forEach(r => { obj[r.key] = r.value; }); + data[key] = obj; + } + resolve(); + }); + }); + } + + Promise.all([ + loadSettings('hero_settings', 'hero'), + loadTable('hero_stats', 'hero_stats'), + loadSettings('about_settings', 'about'), + loadTable('about_features', 'about_features'), + loadTable('services', 'services'), + loadTable('work_schemes', 'work_schemes'), + loadTable('process_steps', 'process_steps'), + loadTable('equipment', 'equipment'), + loadEquipmentGallery(data), + loadTable('geo_regions', 'geo_regions'), + loadSettings('geo_settings', 'geo'), + loadTable('licenses', 'licenses'), + loadTable('license_gallery', 'license_gallery'), + loadTable('public_activities', 'public_activities'), + loadTable('activity_gallery', 'activity_gallery'), + loadTable('partners', 'partners'), + loadSettings('settings', 'settings') + ]).then(() => { + res.json(data); + }); + }); + + app.post('/api/leads', (req, res) => { + const { name, phone, message } = req.body; + if (!name || !phone) return res.status(400).json({ error: 'Имя и телефон обязательны' }); + const stmt = db.prepare(`INSERT INTO leads (name, phone, message) VALUES (?, ?, ?)`); + stmt.run(name.trim(), phone.trim(), (message || '').trim(), function(err) { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + res.status(201).json({ id: this.lastID, message: 'Заявка сохранена' }); + }); + stmt.finalize(); + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/users/index.js b/modules/users/index.js new file mode 100644 index 0000000..ae9f430 --- /dev/null +++ b/modules/users/index.js @@ -0,0 +1,84 @@ +const bcrypt = require('bcryptjs'); + +let db; + +function init(database) { db = database; } + +function setupRoutes(app, auth) { + const { authenticateToken, requireAdmin } = auth; + + app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => { + db.all(`SELECT id, login, full_name, role, created_at FROM users ORDER BY id`, (err, rows) => { + if (err) return res.status(500).json({ error: 'Ошибка БД' }); + res.json(rows); + }); + }); + + app.post('/api/admin/users', authenticateToken, requireAdmin, (req, res) => { + const { login, password, full_name, role } = req.body; + if (!login || !password) return res.status(400).json({ error: 'Логин и пароль обязательны' }); + const hash = bcrypt.hashSync(password, 10); + const stmt = db.prepare(`INSERT INTO users (login, password_hash, full_name, role) VALUES (?, ?, ?, ?)`); + stmt.run(login, hash, full_name || '', role || 'admin', function(err) { + if (err) { + if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Пользователь с таким логином уже существует' }); + return res.status(500).json({ error: 'Ошибка создания' }); + } + db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [this.lastID], (e2, row) => { + res.status(201).json(row); + }); + }); + stmt.finalize(); + }); + + app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + const { login, password, full_name, role } = req.body; + + if (password) { + const hash = bcrypt.hashSync(password, 10); + const stmt = db.prepare(`UPDATE users SET login = ?, password_hash = ?, full_name = ?, role = ? WHERE id = ?`); + stmt.run(login, hash, full_name || '', role || 'admin', id, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка обновления' }); + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [id], (e2, row) => { + res.json(row); + }); + }); + stmt.finalize(); + } else { + const stmt = db.prepare(`UPDATE users SET login = ?, full_name = ?, role = ? WHERE id = ?`); + stmt.run(login, full_name || '', role || 'admin', id, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка обновления' }); + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + db.get(`SELECT id, login, full_name, role, created_at FROM users WHERE id = ?`, [id], (e2, row) => { + res.json(row); + }); + }); + stmt.finalize(); + } + }); + + app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => { + const id = parseInt(req.params.id); + if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' }); + if (id === req.user.id) return res.status(400).json({ error: 'Нельзя удалить самого себя' }); + + const superadmin = process.env.ADMIN_USERNAME || 'admin'; + db.get(`SELECT login FROM users WHERE id = ?`, [id], (e1, row) => { + if (row && row.login === superadmin) { + return res.status(400).json({ error: 'Нельзя удалить суперадминистратора из .env' }); + } + const stmt = db.prepare(`DELETE FROM users WHERE id = ?`); + stmt.run(id, function(err) { + if (err) return res.status(500).json({ error: 'Ошибка удаления' }); + if (this.changes === 0) return res.status(404).json({ error: 'Не найдено' }); + res.json({ message: 'Пользователь удалён' }); + }); + stmt.finalize(); + }); + }); +} + +module.exports = { init, setupRoutes }; diff --git a/modules/workSchemes/index.js b/modules/workSchemes/index.js new file mode 100644 index 0000000..0fa9567 --- /dev/null +++ b/modules/workSchemes/index.js @@ -0,0 +1,12 @@ +const { createCrudRoutes } = require('../database/crud'); + +let db; +function init(database) { db = database; } + +function setupRoutes(app, auth) { + createCrudRoutes(app, auth, 'work_schemes', { + fields: ['svg_icon', 'title', 'description', 'items', 'sort_order', 'is_active'] + }); +} + +module.exports = { init, setupRoutes }; diff --git a/package.json b/package.json index f8bf237..bfea10c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "multer": "^2.2.0", "sharp": "^0.34.5", "sqlite3": "^6.0.1" }, diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..4247665 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,989 @@ + + + + + +Админ-панель | СТС-Авто + + + + + + +
+ +
+ + + +
+ + + + + diff --git a/public/index.html b/public/index.html index 2b28ba0..835ad07 100644 --- a/public/index.html +++ b/public/index.html @@ -278,6 +278,19 @@ img { max-width: 100%; display: block; } .equipment-card-image { height: clamp(160px, 25vw, 220px); overflow: hidden; position: relative; } .equipment-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; } .equipment-card:hover .equipment-card-image img { transform: scale(1.05); } +.equipment-card-image:hover .equip-nav { opacity: 1; } +.equip-nav { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,.5); color:#fff; border:none; cursor:pointer; padding:8px 12px; border-radius:3px; font-size:18px; z-index:3; opacity:0; transition:opacity .3s; } +.equip-nav.prev { left:8px; } +.equip-nav.next { right:8px; } +.equip-dots { position: absolute; bottom:8px; left:50%; transform:translateX(-50%); display:flex; gap:6px; z-index:3; } +.equip-dot { width:8px; height:8px; border-radius:50%; background:rgba(255,255,255,.4); border:none; cursor:pointer; padding:0; transition:all .3s; } +.equip-dot.active { background:var(--orange); width:20px; border-radius:4px; } +.equip-fullview { position:fixed; inset:0; background:rgba(0,0,0,.93); z-index:10001; display:flex; align-items:center; justify-content:center; cursor:pointer; opacity:0; visibility:hidden; transition:all .3s; } +.equip-fullview.open { opacity:1; visibility:visible; } +.equip-fullview img { max-width:90vw; max-height:90vh; object-fit:contain; border-radius:4px; } +.equip-fullview-close { position:absolute; top:20px; right:20px; width:40px; height:40px; background:var(--orange); border-radius:50%; border:none; color:#fff; font-size:20px; cursor:pointer; z-index:2; display:flex; align-items:center; justify-content:center; } +.equipment-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; } +.equipment-card:hover .equipment-card-image img { transform: scale(1.05); } .equipment-card-image .equip-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: clamp(6px, 1.2vw, 10px) clamp(10px, 1.8vw, 16px); background: linear-gradient(transparent, rgba(0,0,0,0.7)); @@ -296,7 +309,7 @@ img { max-width: 100%; display: block; } font-family: 'Montserrat', sans-serif; text-transform: uppercase; letter-spacing: 0.5px; } .equipment-age-badge::before { - content: '✓'; + content: '\2713'; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; background: #27AE60; color: #fff; border-radius: 50%; font-size: 10px; @@ -653,13 +666,11 @@ img { max-width: 100%; display: block; } - - -
-
-
- Строительство трубопровода -
-
-
-
- - Лицензии СРО и НОСТРОЙ -
-

демонтаж нефтегазопроводов по России, утилизация отходов

-

Собственная спецтехника. Лицензии СРО и НОСТРОЙ. Более {years} лет опыта. Работаем на опасных производственных объектах без привлечения субподрядчиков.

-
- - -
- - г. Екатеринбург, ул. Московская, 11, оф. 702 -
-
- -
-
0
лет на рынке
-
0
объектов выполнено
-
0
единиц техники
-
0
федеральных округов
-
-
-
+
+
-
-
-
-

О компании

-

Компания «СТС-Авто» — надёжный партнёр в сфере демонтажа, оказывает услуги по демонтажу магистральных трубопроводов, объектов инфраструктуры, зданий, сооружений и разделки жд транспорта, с последующей переработкой и утилизацией для предприятий. Мы работаем на всей территории Российской Федерации.

-
-
-
- Работы на трубопроводах -
-
-

Собственная техника и полный контроль

-

Наш автопарк состоит из более чем 80 единиц современной спецтехники, возраст которой не превышает 5 лет. Мы используем экскаваторы, краны, погрузчики, самосвалы и тралы ведущих мировых производителей: KAMAZ, Komatsu, Zoomlion, SDLG, Соболь, УАЗ.

-

Все работы выполняются исключительно силами компании — без привлечения субподрядчиков. Это гарантирует полное соответствие качества, сроков и ответственности на каждом этапе проекта.

-

Мы работаем в самых сложных условиях: труднодоступные районы, заболоченные территории, Крайний Север. Гибкие варианты оплаты: работаем по договору, включая вариант расчёта трубой за услуги (бартер с металлоломом после демонтажа).

-
-
-
-
Парк техники не старше 5 летБолее 80 единиц современной спецтехники
-
-
-
-
Без субподрядчиковВсе работы силами компании
-
-
-
-
Сложные условияБолота, Крайний Север, труднодоступные районы
-
-
-
-
Гибкая оплатаВключая расчёт трубой (бартер)
-
-
-
-
-
+
-
-
-

Наши услуги

-

Полный спектр работ по демонтажу и утилизации трубопроводной инфраструктуры. Работаем на опасных производственных объектах с собственным парком спецтехники.

-
-
-
-
-

Демонтаж нефтегазопроводов, утилизация отходов

-

Выполняем полный цикл работ по демонтажу магистральных и промысловых трубопроводов любого типа и диаметра, а также утилизацию отходов.

-
    -
  • Нефтепроводы, газопроводы, водоводы — все типы магистральных и промысловых сетей
  • -
  • Полный цикл: от вскрытия и вырезки дефектных участков до вывоза и сдачи металлолома
  • -
  • Работа с трубопроводами любого диаметра в любых климатических условиях
  • -
  • Разделка ж/д транспорта, переработка и утилизация для предприятий
  • -
-
-
-
-

Техническая рекультивация земель

-

Восстановление нарушенных земель после проведения строительных и ремонтных работ.

-
    -
  • Засыпка траншей, планировка, восстановление плодородного слоя
  • -
  • Работа с нефтезагрязнёнными, засоленными землями
  • -
  • Сдача по актам под контролем экологов
  • -
-
-
-
-

Расчистка территории от мелколесья и ДКР

-

Подготовка полосы отвода: расчистка от древесно-кустарниковой растительности.

-
    -
  • Валка, корчёвка, дробление пней
  • -
  • Планировка полосы отвода
  • -
  • Мульчеры и кусторезы на гусеничном ходу
  • -
-
-
-
-

Устройство лежневого настила

-

Обеспечение проходимости для тяжёлой техники по болотам и слабым грунтам.

-
    -
  • Изготовление секций из бревен и бруса
  • -
  • Укладка перекрытий на слабых грунтах
  • -
  • Обеспечение проходимости для тяжёлой гусеничной техники
  • -
-
-
-
+
-
-
-
-

Гибкие схемы работы

-

Мы разработали гибкие схемы работы, позволяющие выбрать оптимальное решение с учётом ваших целей и бюджета.

-
-
-
-
-

Схема 1 — с доставкой заказчику

-

Подходит для компаний, планирующих повторное использование труб или их последующую реализацию. Расходный договор для заказчика.

-
    -
  • Полный цикл работ по демонтажу трубопровода
  • -
  • Резка и маркировка труб в соответствии с требованиями
  • -
  • Транспортировка демонтированных труб на указанные территории хранения
  • -
  • Оформление всей сопроводительной документации (акты приёма-передачи)
  • -
-
-
-
-

Схема 2 — вывоз на базы подрядчика

-

Экономически выгодное решение для заказчиков, заинтересованных в минимизации затрат. Доходный договор для заказчика.

-
    -
  • Полный цикл работ по демонтажу трубопровода
  • -
  • Перевозка и размещение на сертифицированных площадках временного хранения ООО «СТС-АВТО»
  • -
  • Покупка трубы в земле — заказчик получает доход от металлолома
  • -
  • Оформление полного пакета закрывающей документации
  • -
-
-
-
+
-
-
-
-

Этапы работы

-

Прозрачный и отлаженный процесс от первого обращения до сдачи объекта. Каждый этап контролируется инженерами компании.

-
-
-
-
1
-
-

Заявка

-

Вы оставляете заявку на сайте, по электронной почте или звоните нам. Наши специалисты оперативно свяжутся с вами для обсуждения деталей проекта, объёма работ и сроков.

-
-
-
-
2
-
-

Выезд специалиста и оценка

-

Наш инженер выезжает на объект для проведения осмотра, оценки объёма работ, определения необходимых ресурсов и техники. По результатам составляется техническое задание и подписывается договор.

-
-
-
-
3
-
-

Выполнение работ

-

Мобилизуем технику и персонал на объект. Выполняем демонтаж, утилизацию отходов и рекультивацию в соответствии с проектной документацией. Ежедневные отчёты о ходе работ.

-
-
-
-
4
-
-

Сдача объекта

-

По завершении всех работ подписывается акт выполненных работ, передаётся полный комплект закрывающей документации. При необходимости — сопровождение сдачи объекта экспертизе.

-
-
-
-
+
-
-
-
-

Наша спецтехника

-

Собственный парк из более чем 80 единиц современной спецтехники ведущих мировых производителей. Возраст всей техники — не старше 5 лет.

-
-
- - -
-
- Автокран КАМАЗ КС-55717К-3 -
КАМАЗ
-
-
-

Автокран КАМАЗ КС-55717К-3

-
Не старше 5 лет
-
10 единиц
-
-
Грузоподъёмность25 тонн
-
Вылет стрелыдо 28 м
-
ШассиПолноприводное 6×6
-
-
-
- - -
-
- Автокран Zoomlion ZTC300 -
Zoomlion
-
-
-

Автокран Zoomlion ZTC300

-
Не старше 5 лет
-
2 единицы
-
-
Грузоподъёмность30 тонн
-
Вылет стрелыдо 32 м
-
СтрелаТелескопическая
-
-
-
- - -
-
- Экскаватор Komatsu PC210-10M0 -
Komatsu
-
-
-

Экскаватор гусеничный Komatsu PC210-10M0

-
Не старше 5 лет
-
20 единиц
-
-
Масса21 тонна
-
Объём ковша1.0–1.2 м³
-
Глубина копаниядо 6.5 м
-
-
-
- - -
-
- Экскаватор Zoomlion ZE215E -
Zoomlion
-
-
-

Экскаватор гусеничный Zoomlion ZE215E

-
Не старше 5 лет
-
2 единицы
-
-
Масса21.5 тонны
-
Объём ковша1.1 м³
-
АналогKomatsu PC210
-
-
-
- - -
-
- Погрузчик SDLG LG933L -
SDLG
-
-
-

Погрузчик колёсный SDLG LG933L

-
Не старше 5 лет
-
2 единицы
-
-
Грузоподъёмность3 тонны
-
Объём ковша1.7 м³
-
ТипКолёсный
-
-
-
- - -
-
- Соболь 4×4 -
Соболь
-
-
-

Соболь 4×4 грузо-пассажирский

-
Не старше 5 лет
-
13 единиц
-
-
Грузоподъёмностьдо 1 т
-
ПриводПолный 4×4
-
НазначениеДоставка персонала и запчастей
-
-
-
- - -
-
- Ломовоз с КМУ -
Ломовоз
-
-
-

Ломовоз с КМУ (кран-манипулятор)

-
Не старше 5 лет
-
3 единицы
-
-
Грузоподъёмность КМУдо 8 т
-
Объём платформыдо 20 м³
-
НазначениеВывоз металлолома
-
-
-
- - -
-
- Седельный тягач Камаз 53504-50 -
КамАЗ
-
-
-

Седельный тягач Камаз 53504-50

-
Не старше 5 лет
-
6 единиц
-
-
Тяговый классдо 50 т
-
ПриводПолный привод
-
НазначениеПеревозка негабарита и тралов
-
-
-
- - -
-
- Экскаватор-амфибия -
Амфибия
-
-
-

Экскаватор-амфибия на понтонной платформе

-
Не старше 5 лет
-
4 единицы
-
-
ТипГусеничный, плавающий
-
РаботаетНа болотах и водных преградах
-
ОсобенностьТам, где не видно дна
-
-
-
- - -
-
- УАЗ Пикап -
УАЗ
-
-
-

УАЗ Пикап (полный привод)

-
Не старше 5 лет
-
Несколько единиц
-
-
ПриводПолный привод
-
НазначениеДоставка запчастей на месторождения
-
-
-
- - -
-
- Тралы тяжеловозы -
Тралы
-
-
-

Тралы тяжеловозы для доставки спецтехники

-
Не старше 5 лет
-
3 единицы
-
-
Тяговый классдо 50 т
-
НазначениеДоставка спецтехники на объекты
-
-
-
- -
-
+
-
-
-
-

География присутствия

-

Работаем во всех федеральных округах Российской Федерации. Особые условия для труднодоступных районов Крайнего Севера и заболоченных территорий.

-
-
-
-
-
УрФО
-
СФО
-
ПФО
-
СЗФО
-
ДФО
-
ЮФО
-
-
-

🔧 Труднодоступные районы

-

Мы располагаем специализированной техникой для работы в болотах ХМАО-Югры, ЯНАО, на севере Красноярского края, в Иркутской области. Гусеничная техника и лежневые настилы обеспечивают проходимость даже в самых сложных условиях.

-
-
-
-
- География присутствия СТС-Авто -
-
-
-
+
-
-
-
-

Лицензии и разрешения

-

Все необходимые допуски и сертификаты для выполнения работ на опасных производственных объектах. Полная прозрачность и соответствие требованиям законодательства РФ.

-
-
-
-
-

СРО — допуск к работам на опасных объектах

Свидетельство о допуске к работам на опасных производственных объектах нефтегазового комплекса.

-
-
-
-

Членство в НОСТРОЙ

Действующее членство в Национальном объединении строителей — допуск к строительным работам.

-
-
-
-

Сертификат ISO 9001 (качество)

Международный стандарт системы менеджмента качества.

-
-
-
-

Сертификат OHSAS 18001 (охрана труда)

Система менеджмента охраны труда и промышленной безопасности.

-
-
-
-

Договор ДМС для сотрудников

Добровольное медицинское страхование всех сотрудников.

-
-
-
-

Лицензия на заготовку и переработку лома

Лицензия на заготовку, хранение и переработку лома черных металлов.

-
-
- -
+
-
-
-
-

Общественная деятельность

-

Компания «СТС-Авто» активно участвует в социальной и общественной жизни регионов присутствия.

-
-
-
-
- -
-

Спонсорство

-

Поддерживаем спортивные, культурные и общественные мероприятия в регионах присутствия. Помогаем развитию местных сообществ.

-
-
-
- -
-

Благотворительность

-

Оказываем адресную помощь детским домам, ветеранским и общественным организациям. Участвуем в социальных проектах.

-
-
-
- -
-

Экология

-

Участвуем в экологических акциях, субботниках и проектах по озеленению. Следим за снижением воздействия на окружающую среду.

-
-
-
- -
-

Поддержка образования

-

Сотрудничаем с профильными вузами и колледжами. Организуем практику для студентов, участвуем в ярмарках вакансий.

-
-
- -
+
+
+ +
+
+
+ +
+
- - -
-
-
-

Свяжитесь с нами

-

Оставьте заявку или позвоните — мы оперативно ответим на ваши вопросы и рассчитаем стоимость работ.

-
-
-
-
- -
-
- -
-
-
-

Адрес

г. Екатеринбург, ул. Московская, 11, оф. 702

-
-
-
-

Режим работы

Пн–Пт: 9:00 – 18:00
Сб: 10:00 – 15:00

-
-
-
- Контакты СТС-Авто -
-
-
-
-

Оставить заявку

-

Заполните форму и мы свяжемся с вами в ближайшее время

-
-
- - -
-
- - -
-
- - -
- - -
-
- -

Заявка отправлена!

-

Мы свяжемся с вами в ближайшее время

-
-
-
-
-
-
+
+ + +
- -
-
-
-

Наши партнёры

-

Сотрудничаем с ведущими компаниями нефтегазового сектора.

-
-
-
- Газпром - Роснефть - Сибур - Сургутнефтегаз - Транснефть - Газпром - Роснефть - Сибур - Сургутнефтегаз - Транснефть -
-
-
-
- - - @@ -1398,79 +797,445 @@ img { max-width: 100%; display: block; } - diff --git a/server.js b/server.js index b2c9d6b..53ce26c 100644 --- a/server.js +++ b/server.js @@ -18,118 +18,102 @@ const supportedFormats = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff']; async function convertImagesToWebp() { if (!fs.existsSync(imgDir)) { - console.log('📁 Папка img не найдена, пропускаем конвертацию'); + console.log('Папка img не найдена, пропускаем конвертацию'); return; } - const files = fs.readdirSync(imgDir); const toConvert = files.filter(f => { const ext = path.extname(f).toLowerCase(); return supportedFormats.includes(ext) && !f.endsWith('.webp'); }); - if (toConvert.length === 0) { - console.log('✅ Все изображения уже в формате WebP'); + console.log('Все изображения уже в формате WebP'); return; } - - console.log(`🔄 Конвертация ${toConvert.length} изображений в WebP...`); - + console.log(`Конвертация ${toConvert.length} изображений в WebP...`); for (const file of toConvert) { const inputPath = path.join(imgDir, file); const outputPath = path.join(imgDir, path.basename(file, path.extname(file)) + '.webp'); - try { - await sharp(inputPath) - .webp({ quality: 85 }) - .toFile(outputPath); + await sharp(inputPath).webp({ quality: 85 }).toFile(outputPath); fs.unlinkSync(inputPath); - console.log(` ✅ ${file} → ${path.basename(outputPath)}`); + console.log(` ${file} -> ${path.basename(outputPath)}`); } catch (err) { - console.error(` ❌ Ошибка конвертации ${file}:`, err.message); + console.error(` Ошибка конвертации ${file}:`, err.message); } } - - console.log('✅ Конвертация завершена'); + console.log('Конвертация завершена'); } -// Middleware app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); -// Ensure data directory and database const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir); + +[ + 'equipment_images', 'partner_images', 'license_images', + 'activity_images', 'hero_images' +].forEach(dir => { + const p = path.join(dataDir, dir); + if (!fs.existsSync(p)) fs.mkdirSync(p); + app.use('/data/' + dir, express.static(p)); +}); + const dbPath = path.join(dataDir, 'bookings.db'); const db = new sqlite3.Database(dbPath); -db.run(`CREATE TABLE IF NOT EXISTS leads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - phone TEXT NOT NULL, - message TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -)`); +const dbModule = require('./modules/database'); +const crudModule = require('./modules/database/crud'); +const authModule = require('./modules/auth'); +const heroModule = require('./modules/hero'); +const aboutModule = require('./modules/about'); +const servicesModule = require('./modules/services'); +const workSchemesModule = require('./modules/workSchemes'); +const processStepsModule = require('./modules/processSteps'); +const equipmentModule = require('./modules/equipment'); +const geographyModule = require('./modules/geography'); +const licensesModule = require('./modules/licenses'); +const activitiesModule = require('./modules/activities'); +const partnersModule = require('./modules/partners'); +const settingsModule = require('./modules/settings'); +const usersModule = require('./modules/users'); +const leadsModule = require('./modules/leads'); +const siteDataModule = require('./modules/siteData'); -// API: POST /api/leads – сохранить новую заявку (публичный) -app.post('/api/leads', (req, res) => { - const { name, phone, message } = req.body; - if (!name || !phone) { - return res.status(400).json({ error: 'Missing required fields: name, phone' }); - } - const stmt = db.prepare(`INSERT INTO leads (name, phone, message) VALUES (?, ?, ?)`); - stmt.run(name.trim(), phone.trim(), (message || '').trim(), function(err) { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Database error' }); - } - console.log(`✅ Новая заявка #${this.lastID}: ${name} | ${phone}`); - res.status(201).json({ id: this.lastID, message: 'Lead saved' }); - }); - stmt.finalize(); +[ + crudModule, authModule, heroModule, aboutModule, + servicesModule, workSchemesModule, processStepsModule, + equipmentModule, geographyModule, licensesModule, + activitiesModule, partnersModule, settingsModule, + usersModule, leadsModule, siteDataModule +].forEach(m => m.init(db)); + +const auth = { + authenticateToken: authModule.authenticateToken, + requireAdmin: authModule.requireAdmin +}; + +authModule.setupRoutes(app); +heroModule.setupRoutes(app, auth); +aboutModule.setupRoutes(app, auth); +servicesModule.setupRoutes(app, auth); +workSchemesModule.setupRoutes(app, auth); +processStepsModule.setupRoutes(app, auth); +equipmentModule.setupRoutes(app, auth); +geographyModule.setupRoutes(app, auth); +licensesModule.setupRoutes(app, auth); +activitiesModule.setupRoutes(app, auth); +partnersModule.setupRoutes(app, auth); +settingsModule.setupRoutes(app, auth); +usersModule.setupRoutes(app, auth); +leadsModule.setupRoutes(app, auth); +siteDataModule.setupRoutes(app); + +app.get('/admin', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'admin.html')); }); -// API: GET /api/leads – получить список всех заявок (требуется API-ключ) -app.get('/api/leads', (req, res) => { - const providedKey = req.headers['x-api-key']; - if (!providedKey || providedKey !== API_KEY) { - return res.status(401).json({ error: 'Invalid or missing API key' }); - } - db.all(`SELECT id, name, phone, message, created_at FROM leads ORDER BY created_at DESC`, (err, rows) => { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Database error' }); - } - res.json(rows); - }); -}); - -// API: DELETE /api/leads/:id – удалить заявку (требуется API-ключ) -app.delete('/api/leads/:id', (req, res) => { - const providedKey = req.headers['x-api-key']; - if (!providedKey || providedKey !== API_KEY) { - return res.status(401).json({ error: 'Invalid or missing API key' }); - } - const id = parseInt(req.params.id); - if (isNaN(id)) { - return res.status(400).json({ error: 'Invalid ID' }); - } - const stmt = db.prepare(`DELETE FROM leads WHERE id = ?`); - stmt.run(id, function(err) { - if (err) { - console.error(err); - return res.status(500).json({ error: 'Database error' }); - } - if (this.changes === 0) { - return res.status(404).json({ error: 'Lead not found' }); - } - console.log(`🗑️ Удалена заявка #${id}`); - res.json({ message: 'Lead deleted' }); - }); - stmt.finalize(); -}); - -// Serve frontend app.get('/', (req, res) => { const years = new Date().getFullYear() - 2011; const htmlPath = path.join(__dirname, 'public', 'index.html'); @@ -137,8 +121,15 @@ app.get('/', (req, res) => { res.send(html.replace(/\{years\}/g, String(years))); }); -app.listen(PORT, async () => { - await convertImagesToWebp(); - console.log(`✅ server running on http://localhost:${PORT}`); - console.log(`🔑 API Key: ${API_KEY}`); -}); \ No newline at end of file +convertImagesToWebp().then(() => console.log('Images ready')); + +(async () => { + await dbModule.init(db); + await authModule.syncAdmin(); + console.log('Database initialized, admin synced'); + + app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + console.log(`Admin panel: http://localhost:${PORT}/admin`); + }); +})();