Initial commit: Voda Landing (Cherepovets)

This commit is contained in:
admin
2026-04-30 10:55:23 +03:00
commit add947d64f
10 changed files with 3946 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# Dependencies
node_modules/
npm-debug.log*
# Env & secrets
.env
.env.local
.env.production
*.pem
*.key
# User uploads (will be persisted via volume)
public/uploads/*
!public/uploads/.gitkeep
# OS / Editor
.DS_Store
Thumbs.db
*.swp
.vscode/
.idea/
# Logs
*.log
# Backups
*.bak
*.bak2
*.old
+21
View File
@@ -0,0 +1,21 @@
# Voda Landing (cherepovets-landing)
Лендинг компании водоснабжения в Череповце. Простой Node.js + Express сервер на порту 3006 с админкой и галереей проектов.
## Стек
- Express 4
- PostgreSQL (`voda_landing`)
- express-session + bcryptjs (авторизация админа)
- multer (загрузка фото)
## Структура
- `server.js` — основной сервер
- `public/` — статика (главная, стили, картинки)
- `public/uploads/` — пользовательские загрузки (не в git)
- `admin/` — админка (одностраничная)
## Запуск
```bash
npm install
node server.js
```
+645
View File
@@ -0,0 +1,645 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель | Вода МКД</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
a{color:#60a5fa;text-decoration:none}
/* Login */
.login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
.login-box{background:#1e293b;border:1px solid #334155;border-radius:1rem;padding:2.5rem;width:100%;max-width:380px}
.login-box h1{font-size:1.25rem;margin-bottom:1.5rem;text-align:center}
.login-box input{width:100%;padding:.875rem 1rem;background:#0f172a;border:1px solid #334155;border-radius:.5rem;color:#fff;font-size:.9rem;margin-bottom:1rem}
.login-box input:focus{outline:none;border-color:#3b82f6}
.login-box button{width:100%;padding:.875rem;background:linear-gradient(135deg,#3b82f6,#06b6d4);border:none;border-radius:.5rem;color:#fff;font-weight:600;font-size:.9rem;cursor:pointer}
.login-box .error{color:#f87171;font-size:.8rem;text-align:center;margin-top:.5rem;display:none}
/* Layout */
.app{display:none;min-height:100vh}
.sidebar{position:fixed;left:0;top:0;bottom:0;width:240px;background:#1e293b;border-right:1px solid #334155;padding:1.5rem 0;overflow-y:auto;z-index:50}
.sidebar-logo{padding:0 1.25rem 1.5rem;border-bottom:1px solid #334155;margin-bottom:1rem}
.sidebar-logo h2{font-size:1rem;color:#f1f5f9}
.sidebar-logo span{font-size:.7rem;color:#64748b}
.nav-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1.25rem;color:#94a3b8;font-size:.875rem;cursor:pointer;transition:all .2s;border-left:3px solid transparent}
.nav-item:hover{background:rgba(59,130,246,.08);color:#e2e8f0}
.nav-item.active{background:rgba(59,130,246,.12);color:#60a5fa;border-left-color:#3b82f6}
.nav-item svg{width:18px;height:18px;flex-shrink:0}
.nav-divider{height:1px;background:#334155;margin:.75rem 0}
.nav-bottom{position:absolute;bottom:0;left:0;right:0;padding:1rem 1.25rem;border-top:1px solid #334155}
.nav-bottom a{font-size:.8rem;color:#64748b}
.main{margin-left:240px;padding:2rem}
.page{display:none}
.page.active{display:block}
.page-title{font-size:1.5rem;font-weight:700;margin-bottom:1.5rem;display:flex;align-items:center;justify-content:space-between}
/* Cards & Forms */
.card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.5rem;margin-bottom:1rem}
.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem}
.card-header h3{font-size:1rem}
.form-grid{display:grid;gap:.75rem}
.form-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.form-label{font-size:.75rem;color:#94a3b8;margin-bottom:.25rem;display:block}
.form-input{width:100%;padding:.625rem .75rem;background:#0f172a;border:1px solid #334155;border-radius:.5rem;color:#fff;font-size:.85rem;font-family:inherit}
.form-input:focus{outline:none;border-color:#3b82f6}
textarea.form-input{resize:vertical;min-height:5rem}
.form-input-sm{max-width:100px}
.btn{display:inline-flex;align-items:center;gap:.375rem;padding:.5rem 1rem;border:none;border-radius:.5rem;font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s}
.btn-primary{background:linear-gradient(135deg,#3b82f6,#06b6d4);color:#fff}
.btn-primary:hover{box-shadow:0 4px 15px rgba(59,130,246,.3)}
.btn-danger{background:#991b1b;color:#fff}
.btn-danger:hover{background:#b91c1c}
.btn-ghost{background:transparent;border:1px solid #334155;color:#94a3b8}
.btn-ghost:hover{border-color:#3b82f6;color:#60a5fa}
.btn-sm{padding:.375rem .75rem;font-size:.75rem}
.btn-save{padding:.625rem 1.5rem;font-size:.875rem}
/* Table */
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:.625rem .75rem;font-size:.75rem;color:#64748b;font-weight:600;text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid #334155}
td{padding:.75rem;font-size:.85rem;border-bottom:1px solid rgba(51,65,85,.3)}
tr:hover td{background:rgba(59,130,246,.04)}
/* Status badges */
.badge{display:inline-block;padding:.25rem .625rem;border-radius:1rem;font-size:.7rem;font-weight:600}
.badge-new{background:rgba(59,130,246,.15);color:#60a5fa}
.badge-process{background:rgba(234,179,8,.15);color:#facc15}
.badge-done{background:rgba(34,197,94,.15);color:#22c55e}
.badge-reject{background:rgba(239,68,68,.15);color:#f87171}
/* Stat cards */
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.25rem}
.stat-card .num{font-size:1.75rem;font-weight:800;background:linear-gradient(135deg,#60a5fa,#06b6d4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.stat-card .label{font-size:.75rem;color:#64748b;margin-top:.25rem}
/* Toast */
.toast{position:fixed;bottom:2rem;right:2rem;background:#22c55e;color:#fff;padding:.75rem 1.25rem;border-radius:.5rem;font-size:.85rem;font-weight:600;transform:translateY(100px);opacity:0;transition:all .3s;z-index:999}
.toast.show{transform:translateY(0);opacity:1}
/* Item row */
.item-row{display:flex;align-items:center;gap:.75rem;padding:.75rem 0;border-bottom:1px solid rgba(51,65,85,.3)}
.item-row:last-child{border-bottom:none}
.item-icon{width:40px;height:40px;background:rgba(59,130,246,.1);border-radius:.5rem;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.item-info{flex:1;min-width:0}
.item-info h4{font-size:.875rem;font-weight:600}
.item-info p{font-size:.75rem;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.item-actions{display:flex;gap:.5rem;flex-shrink:0}
/* Modal */
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center;padding:1rem}
.modal-bg.open{display:flex}
.modal{background:#1e293b;border:1px solid #334155;border-radius:1rem;padding:2rem;width:100%;max-width:500px;max-height:90vh;overflow-y:auto}
.modal h3{font-size:1.1rem;margin-bottom:1.25rem}
.modal-actions{display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.5rem}
/* Responsive */
@media(max-width:768px){
.sidebar{transform:translateX(-100%);transition:transform .3s;width:260px}
.sidebar.open{transform:translateX(0)}
.main{margin-left:0;padding:1rem}
.mob-header{display:flex;align-items:center;gap:1rem;padding:1rem;background:#1e293b;border-bottom:1px solid #334155;position:sticky;top:0;z-index:40}
.mob-burger{background:none;border:none;color:#fff;font-size:1.25rem;cursor:pointer}
.form-grid-2{grid-template-columns:1fr}
.stats-row{grid-template-columns:repeat(2,1fr)}
}
@media(min-width:769px){.mob-header{display:none}}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<!-- Login -->
<div class="login-wrap" id="loginScreen">
<div class="login-box">
<h1>🔐 Вход в админку</h1>
<input type="password" id="loginPass" placeholder="Пароль" autofocus>
<button onclick="login()">Войти</button>
<div class="error" id="loginError">Неверный пароль</div>
</div>
</div>
<!-- App -->
<div class="app" id="app">
<!-- Mobile header -->
<div class="mob-header">
<button class="mob-burger" onclick="toggleSidebar()"></button>
<span style="font-weight:700">Админ-панель</span>
</div>
<!-- Sidebar -->
<div class="sidebar" id="sidebar">
<div class="sidebar-logo">
<h2>ВодаМКД</h2>
<span>Панель управления</span>
</div>
<div class="nav-item active" onclick="go('leads')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Заявки</div>
<div class="nav-item" onclick="go('contacts')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2A19.79 19.79 0 0 1 3.07 5.18 2 2 0 0 1 5.08 3h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>Контакты</div>
<div class="nav-item" onclick="go('services')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Услуги</div>
<div class="nav-item" onclick="go('benefits')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>Преимущества</div>
<div class="nav-item" onclick="go('projects')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="6" width="22" height="15" rx="2"/><path d="M1 10h22"/></svg>Объекты</div>
<div class="nav-divider"></div>
<div class="nav-item" onclick="go('hero')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>Герой (шапка)</div>
<div class="nav-item" onclick="go('seo')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>SEO</div>
<div class="nav-item" onclick="go('telegram')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4z"/></svg>Telegram</div>
<div class="nav-item" onclick="go('password')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Пароль</div>
<div class="nav-divider"></div>
<div class="nav-item" onclick="logout()" style="color:#f87171"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>Выйти</div>
</div>
<div class="main">
<!-- LEADS -->
<div class="page active" id="page-leads">
<div class="page-title">Заявки <span id="leadsCount" class="badge badge-new" style="font-size:.85rem"></span></div>
<div class="stats-row" id="leadsStats"></div>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Дата</th><th>Организация</th><th>Имя</th><th>Телефон</th><th>Сообщение</th><th>Статус</th><th></th></tr></thead>
<tbody id="leadsTable"></tbody>
</table></div></div>
</div>
<!-- CONTACTS -->
<div class="page" id="page-contacts">
<div class="page-title">Контакты и реквизиты</div>
<div class="card">
<h3 style="margin-bottom:1rem">Контактные данные</h3>
<div class="form-grid">
<div class="form-grid-2">
<div><label class="form-label">Телефон (отображение)</label><input class="form-input" id="s-phone"></div>
<div><label class="form-label">Телефон (ссылка, +7...)</label><input class="form-input" id="s-phone_raw"></div>
</div>
<div class="form-grid-2">
<div><label class="form-label">Email</label><input class="form-input" id="s-email"></div>
<div><label class="form-label">Адрес</label><input class="form-input" id="s-address"></div>
</div>
<div><label class="form-label">График работы</label><input class="form-input" id="s-work_hours"></div>
</div>
</div>
<div class="card">
<h3 style="margin-bottom:1rem">Реквизиты компании</h3>
<div class="form-grid">
<div class="form-grid-2">
<div><label class="form-label">ИНН</label><input class="form-input" id="s-inn"></div>
<div><label class="form-label">ОГРН</label><input class="form-input" id="s-ogrn"></div>
</div>
<div><label class="form-label">СРО</label><input class="form-input" id="s-sro"></div>
</div>
</div>
<button class="btn btn-primary btn-save" onclick="saveSettings('phone','phone_raw','email','address','work_hours','inn','ogrn','sro')">Сохранить</button>
</div>
<!-- SERVICES -->
<div class="page" id="page-services">
<div class="page-title">Услуги <button class="btn btn-primary btn-sm" onclick="openModal('service')">+ Добавить</button></div>
<div class="card" id="servicesList"></div>
</div>
<!-- BENEFITS -->
<div class="page" id="page-benefits">
<div class="page-title">Преимущества <button class="btn btn-primary btn-sm" onclick="openModal('benefit')">+ Добавить</button></div>
<div class="card" id="benefitsList"></div>
</div>
<!-- PROJECTS -->
<div class="page" id="page-projects">
<div class="page-title">Объекты <button class="btn btn-primary btn-sm" onclick="openModal('project')">+ Добавить</button></div>
<div class="card" id="projectsList"></div>
</div>
<!-- HERO -->
<div class="page" id="page-hero">
<div class="page-title">Герой (шапка сайта)</div>
<div class="card">
<div class="form-grid">
<div><label class="form-label">Бейдж</label><input class="form-input" id="s-hero_badge"></div>
<div><label class="form-label">Заголовок строка 1</label><input class="form-input" id="s-hero_title_1"></div>
<div><label class="form-label">Заголовок строка 2 (градиент)</label><input class="form-input" id="s-hero_title_2"></div>
<div><label class="form-label">Заголовок строка 3</label><input class="form-input" id="s-hero_title_3"></div>
<div><label class="form-label">Подзаголовок</label><textarea class="form-input" id="s-hero_lead"></textarea></div>
<div class="form-grid-2">
<div><label class="form-label">Кнопка основная</label><input class="form-input" id="s-hero_btn_primary"></div>
<div><label class="form-label">Кнопка вторичная</label><input class="form-input" id="s-hero_btn_secondary"></div>
</div>
<h3 style="margin-top:1rem">Статистика</h3>
<div class="form-grid-2">
<div><label class="form-label">Лет на рынке</label><input class="form-input form-input-sm" id="s-stat_years"></div>
<div><label class="form-label">Объектов МКД</label><input class="form-input form-input-sm" id="s-stat_objects"></div>
</div>
<div class="form-grid-2">
<div><label class="form-label">Партнёров УК/ТСЖ</label><input class="form-input form-input-sm" id="s-stat_partners"></div>
<div><label class="form-label">Аварий после ремонта</label><input class="form-input form-input-sm" id="s-stat_accidents"></div>
</div>
</div>
</div>
<button class="btn btn-primary btn-save" onclick="saveSettings('hero_badge','hero_title_1','hero_title_2','hero_title_3','hero_lead','hero_btn_primary','hero_btn_secondary','stat_years','stat_objects','stat_partners','stat_accidents')">Сохранить</button>
</div>
<!-- SEO -->
<div class="page" id="page-seo">
<div class="page-title">SEO настройки</div>
<div class="card">
<div class="form-grid">
<div><label class="form-label">Title (заголовок страницы)</label><input class="form-input" id="s-seo_title"></div>
<div><label class="form-label">Description (мета-описание)</label><textarea class="form-input" id="s-seo_description"></textarea></div>
<div><label class="form-label">Keywords (ключевые слова)</label><input class="form-input" id="s-seo_keywords"></div>
<h3 style="margin-top:1rem">Open Graph (соцсети)</h3>
<div><label class="form-label">OG Title</label><input class="form-input" id="s-og_title"></div>
<div><label class="form-label">OG Description</label><textarea class="form-input" id="s-og_description"></textarea></div>
</div>
</div>
<button class="btn btn-primary btn-save" onclick="saveSettings('seo_title','seo_description','seo_keywords','og_title','og_description')">Сохранить</button>
</div>
<!-- TELEGRAM -->
<div class="page" id="page-telegram">
<div class="page-title">Telegram уведомления</div>
<div class="card">
<div class="form-grid">
<div>
<label class="form-label">Включить уведомления</label>
<select class="form-input" id="s-tg_enabled" style="max-width:200px">
<option value="false">Выключены</option>
<option value="true">Включены</option>
</select>
</div>
<div><label class="form-label">Bot Token</label><input class="form-input" id="s-tg_bot_token" placeholder="123456:ABC-DEF..."></div>
<div><label class="form-label">Chat ID</label><input class="form-input" id="s-tg_chat_id" placeholder="-100123456789"></div>
<p style="font-size:.8rem;color:#64748b;line-height:1.5;margin-top:.5rem">
1. Создайте бота через <a href="https://t.me/BotFather" target="_blank">@BotFather</a> и получите токен.<br>
2. Добавьте бота в группу или напишите ему.<br>
3. Узнайте Chat ID через <a href="https://t.me/getmyid_bot" target="_blank">@getmyid_bot</a>.
</p>
</div>
</div>
<button class="btn btn-primary btn-save" onclick="saveSettings('tg_enabled','tg_bot_token','tg_chat_id')">Сохранить</button>
</div>
<!-- PASSWORD -->
<div class="page" id="page-password">
<div class="page-title">Сменить пароль</div>
<div class="card">
<div class="form-grid" style="max-width:350px">
<div><label class="form-label">Новый пароль</label><input class="form-input" type="password" id="newPass"></div>
<div><label class="form-label">Повторите пароль</label><input class="form-input" type="password" id="newPass2"></div>
</div>
</div>
<button class="btn btn-primary btn-save" onclick="changePass()">Сменить пароль</button>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal-bg" id="modalBg" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
const API = '';
let settings = {};
let currentPage = 'leads';
let editId = null;
// === AUTH ===
async function login() {
const pass = document.getElementById('loginPass').value;
const r = await fetch(API + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pass }) });
if (r.ok) { showApp(); } else { document.getElementById('loginError').style.display = 'block'; }
}
document.getElementById('loginPass').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
async function logout() {
await fetch(API + '/api/logout', { method: 'POST' });
location.reload();
}
async function checkAuth() {
const r = await fetch(API + '/api/auth-check');
const d = await r.json();
if (d.auth) showApp();
}
function showApp() {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('app').style.display = 'block';
loadAll();
}
// === NAVIGATION ===
function go(page) {
currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('sidebar').classList.remove('open');
// Reload data
if (page === 'leads') loadLeads();
if (page === 'services') loadServices();
if (page === 'benefits') loadBenefits();
if (page === 'projects') loadProjects();
}
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); }
// === TOAST ===
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2500);
}
// === LOAD ALL ===
async function loadAll() {
await loadSettings();
loadLeads();
}
// === SETTINGS ===
async function loadSettings() {
const r = await fetch(API + '/api/settings');
settings = await r.json();
// Fill all settings inputs
Object.keys(settings).forEach(key => {
const el = document.getElementById('s-' + key);
if (el) {
if (el.tagName === 'SELECT') el.value = settings[key];
else el.value = settings[key];
}
});
}
async function saveSettings(...keys) {
const data = {};
keys.forEach(k => {
const el = document.getElementById('s-' + k);
if (el) data[k] = el.value;
});
await fetch(API + '/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
toast('Сохранено ✓');
}
// === LEADS ===
async function loadLeads() {
const r = await fetch(API + '/api/leads');
const leads = await r.json();
const newC = leads.filter(l => l.status === 'new').length;
const procC = leads.filter(l => l.status === 'process').length;
const doneC = leads.filter(l => l.status === 'done').length;
document.getElementById('leadsCount').textContent = newC ? newC + ' новых' : '';
document.getElementById('leadsStats').innerHTML = `
<div class="stat-card"><div class="num">${leads.length}</div><div class="label">Всего заявок</div></div>
<div class="stat-card"><div class="num">${newC}</div><div class="label">Новых</div></div>
<div class="stat-card"><div class="num">${procC}</div><div class="label">В работе</div></div>
<div class="stat-card"><div class="num">${doneC}</div><div class="label">Завершено</div></div>
`;
const statusOpts = s => `<select class="form-input" style="width:auto;padding:.25rem .5rem;font-size:.75rem" onchange="updateLead(${0},'${''}')">
<option value="new" ${s==='new'?'selected':''}>Новая</option>
<option value="process" ${s==='process'?'selected':''}>В работе</option>
<option value="done" ${s==='done'?'selected':''}>Завершена</option>
<option value="reject" ${s==='reject'?'selected':''}>Отклонена</option>
</select>`;
const badgeClass = s => s==='new'?'badge-new':s==='process'?'badge-process':s==='done'?'badge-done':'badge-reject';
const badgeText = s => s==='new'?'Новая':s==='process'?'В работе':s==='done'?'Завершена':'Отклонена';
document.getElementById('leadsTable').innerHTML = leads.map(l => `<tr>
<td style="white-space:nowrap">${new Date(l.created_at).toLocaleString('ru')}</td>
<td>${esc(l.company)}</td>
<td>${esc(l.name)}</td>
<td><a href="tel:${esc(l.phone)}">${esc(l.phone)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${esc(l.message)}</td>
<td>
<select class="form-input" style="width:auto;padding:.25rem .5rem;font-size:.75rem" onchange="updateLead(${l.id},this.value)">
<option value="new" ${l.status==='new'?'selected':''}>Новая</option>
<option value="process" ${l.status==='process'?'selected':''}>В работе</option>
<option value="done" ${l.status==='done'?'selected':''}>Завершена</option>
<option value="reject" ${l.status==='reject'?'selected':''}>Отклонена</option>
</select>
</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteLead(${l.id})">✕</button></td>
</tr>`).join('');
}
async function updateLead(id, status) {
await fetch(API + '/api/leads/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
toast('Статус обновлён');
loadLeads();
}
async function deleteLead(id) {
if (!confirm('Удалить заявку?')) return;
await fetch(API + '/api/leads/' + id, { method: 'DELETE' });
toast('Удалено');
loadLeads();
}
// === SERVICES ===
async function loadServices() {
const r = await fetch(API + '/api/services');
const items = await r.json();
document.getElementById('servicesList').innerHTML = items.map(i => `
<div class="item-row">
<div class="item-icon">${i.icon_svg}</div>
<div class="item-info"><h4>${esc(i.title)}</h4><p>${esc(i.description)}</p></div>
<div class="item-actions">
<button class="btn btn-ghost btn-sm" onclick='editItem("service",${JSON.stringify(i).replace(/'/g,"&#39;")})'>✎</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem('services',${i.id})">✕</button>
</div>
</div>
`).join('') || '<p style="color:#64748b;padding:1rem">Нет услуг</p>';
}
// === BENEFITS ===
async function loadBenefits() {
const r = await fetch(API + '/api/benefits');
const items = await r.json();
document.getElementById('benefitsList').innerHTML = items.map(i => `
<div class="item-row">
<div class="item-icon">${i.icon_svg}</div>
<div class="item-info"><h4>${esc(i.title)}</h4><p>${esc(i.description)}</p></div>
<div class="item-actions">
<button class="btn btn-ghost btn-sm" onclick='editItem("benefit",${JSON.stringify(i).replace(/'/g,"&#39;")})'>✎</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem('benefits',${i.id})">✕</button>
</div>
</div>
`).join('') || '<p style="color:#64748b;padding:1rem">Нет преимуществ</p>';
}
// === PROJECTS ===
async function loadProjects() {
const r = await fetch(API + '/api/projects');
const items = await r.json();
document.getElementById('projectsList').innerHTML = items.map(i => `
<div class="item-row">
${i.image_url ? `<img src="${esc(i.image_url)}" style="width:56px;height:56px;object-fit:cover;border-radius:.5rem;flex-shrink:0">` : `<div class="item-icon" style="width:56px;height:56px;background:rgba(59,130,246,.08);display:flex;align-items:center;justify-content:center;border-radius:.5rem;flex-shrink:0"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>`}
<div class="item-info"><h4>${esc(i.title)}</h4><p>${esc(i.address)} · ${esc(i.tag)} · ${esc(i.apartments)} · ${esc(i.duration)}</p></div>
<div class="item-actions">
<button class="btn btn-ghost btn-sm" onclick='editItem("project",${JSON.stringify(i).replace(/'/g,"&#39;")})'>✎</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem('projects',${i.id})">✕</button>
</div>
</div>
`).join('') || '<p style="color:#64748b;padding:1rem">Нет объектов</p>';
}
// === DELETE ITEM ===
async function deleteItem(type, id) {
if (!confirm('Удалить?')) return;
await fetch(API + '/api/' + type + '/' + id, { method: 'DELETE' });
toast('Удалено');
if (type === 'services') loadServices();
if (type === 'benefits') loadBenefits();
if (type === 'projects') loadProjects();
}
// === MODAL ===
function openModal(type, data) {
editId = data ? data.id : null;
const isEdit = !!data;
let html = '';
if (type === 'service' || type === 'benefit') {
const d = data || {};
html = `<h3>${isEdit ? 'Редактировать' : 'Добавить'} ${type === 'service' ? 'услугу' : 'преимущество'}</h3>
<div class="form-grid">
<div><label class="form-label">Название</label><input class="form-input" id="m-title" value="${esc(d.title||'')}"></div>
<div><label class="form-label">Описание</label><textarea class="form-input" id="m-desc">${esc(d.description||'')}</textarea></div>
<div><label class="form-label">SVG иконка</label><textarea class="form-input" id="m-icon" style="font-family:monospace;font-size:.75rem">${esc(d.icon_svg||'')}</textarea></div>
<div><label class="form-label">Порядок сортировки</label><input class="form-input form-input-sm" type="number" id="m-sort" value="${d.sort_order||0}"></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="saveModal('${type}')">${isEdit ? 'Сохранить' : 'Добавить'}</button>
</div>`;
}
if (type === 'project') {
const d = data || {};
html = `<h3>${isEdit ? 'Редактировать' : 'Добавить'} объект</h3>
<div class="form-grid">
<div>
<label class="form-label">Фото объекта</label>
<div id="m-photo-area" style="border:2px dashed #334155;border-radius:.75rem;padding:1rem;text-align:center;cursor:pointer;position:relative;min-height:120px;display:flex;align-items:center;justify-content:center;overflow:hidden" onclick="document.getElementById('m-photo-input').click()">
${d.image_url ? `<img src="${esc(d.image_url)}" id="m-photo-preview" style="max-width:100%;max-height:200px;border-radius:.5rem">` : `<div id="m-photo-placeholder"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="1.5" style="margin-bottom:.5rem"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg><br><span style="color:#64748b;font-size:.8rem">Нажмите для загрузки фото<br>JPG, PNG, WebP до 10МБ</span></div>`}
</div>
<input type="file" id="m-photo-input" accept="image/*" style="display:none" onchange="uploadPhoto(this)">
<input type="hidden" id="m-image-url" value="${esc(d.image_url||'')}">
</div>
<div><label class="form-label">Название</label><input class="form-input" id="m-title" value="${esc(d.title||'')}"></div>
<div><label class="form-label">Адрес</label><input class="form-input" id="m-address" value="${esc(d.address||'')}"></div>
<div class="form-grid-2">
<div><label class="form-label">Тег (Капремонт, Модернизация...)</label><input class="form-input" id="m-tag" value="${esc(d.tag||'')}"></div>
<div><label class="form-label">Статус</label><input class="form-input" id="m-status" value="${esc(d.status||'Сдано')}"></div>
</div>
<div class="form-grid-2">
<div><label class="form-label">Квартиры</label><input class="form-input" id="m-apartments" value="${esc(d.apartments||'')}"></div>
<div><label class="form-label">Срок</label><input class="form-input" id="m-duration" value="${esc(d.duration||'')}"></div>
</div>
<div><label class="form-label">Порядок сортировки</label><input class="form-input form-input-sm" type="number" id="m-sort" value="${d.sort_order||0}"></div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="saveModal('project')">${isEdit ? 'Сохранить' : 'Добавить'}</button>
</div>`;
}
document.getElementById('modal').innerHTML = html;
document.getElementById('modalBg').classList.add('open');
}
function editItem(type, data) { openModal(type, data); }
function closeModal() { document.getElementById('modalBg').classList.remove('open'); editId = null; }
async function saveModal(type) {
let url, body;
if (type === 'service' || type === 'benefit') {
const endpoint = type === 'service' ? 'services' : 'benefits';
body = {
title: document.getElementById('m-title').value,
description: document.getElementById('m-desc').value,
icon_svg: document.getElementById('m-icon').value,
sort_order: parseInt(document.getElementById('m-sort').value) || 0,
active: true
};
url = API + '/api/' + endpoint + (editId ? '/' + editId : '');
}
if (type === 'project') {
body = {
title: document.getElementById('m-title').value,
address: document.getElementById('m-address').value,
tag: document.getElementById('m-tag').value,
status: document.getElementById('m-status').value,
apartments: document.getElementById('m-apartments').value,
duration: document.getElementById('m-duration').value,
image_url: document.getElementById('m-image-url').value,
sort_order: parseInt(document.getElementById('m-sort').value) || 0,
active: true
};
url = API + '/api/projects' + (editId ? '/' + editId : '');
}
await fetch(url, { method: editId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
closeModal();
toast(editId ? 'Обновлено ✓' : 'Добавлено ✓');
if (type === 'service') loadServices();
if (type === 'benefit') loadBenefits();
if (type === 'project') loadProjects();
}
// === PASSWORD ===
async function changePass() {
const p1 = document.getElementById('newPass').value;
const p2 = document.getElementById('newPass2').value;
if (p1 !== p2) return toast('Пароли не совпадают!');
if (p1.length < 4) return toast('Минимум 4 символа!');
const r = await fetch(API + '/api/change-password', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: p1 }) });
if (r.ok) toast('Пароль изменён ✓');
}
// === UPLOAD PHOTO ===
async function uploadPhoto(input) {
const file = input.files[0];
if (!file) return;
const area = document.getElementById('m-photo-area');
area.innerHTML = '<span style="color:#64748b;font-size:.8rem">Загрузка...</span>';
const fd = new FormData();
fd.append('image', file);
try {
const r = await fetch(API + '/api/upload', { method: 'POST', body: fd });
const d = await r.json();
if (d.url) {
document.getElementById('m-image-url').value = d.url;
area.innerHTML = `<img src="${d.url}" id="m-photo-preview" style="max-width:100%;max-height:200px;border-radius:.5rem">`;
toast('Фото загружено ✓');
} else {
area.innerHTML = '<span style="color:#f87171;font-size:.8rem">Ошибка загрузки</span>';
}
} catch(e) {
area.innerHTML = '<span style="color:#f87171;font-size:.8rem">Ошибка загрузки</span>';
}
}
// Util
function esc(s) { if (!s) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Init
checkAuth();
</script>
</body>
</html>
+1168
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "voda-landing",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"express": "^4.21.2",
"express-session": "^1.19.0",
"multer": "^2.0.2",
"pg": "^8.18.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+697
View File
@@ -0,0 +1,697 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Инженерные системы МКД — Череповец | Водоснабжение и канализация</title>
<meta name="description" content="Проектирование, капитальный ремонт и обслуживание систем водоснабжения и канализации для многоквартирных домов в Череповце">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Top bar with contacts -->
<div class="top-bar">
<div class="container top-bar-inner">
<div class="top-bar-left">
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:4px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Череповец, Вологодская обл.</span>
<span class="top-bar-divider">|</span>
<span>Пн-Пт: 8:00 - 18:00</span>
</div>
<div class="top-bar-right">
<a href="mailto:info@ingener-mkd.ru"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:4px"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 7l-10 7L2 7"/></svg>info@ingener-mkd.ru</a>
</div>
</div>
</div>
<!-- Navigation -->
<nav id="nav">
<div class="container">
<div class="nav-content">
<div class="logo">
<div class="logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
</svg>
</div>
<div class="logo-text">
<span class="logo-name">ИнженерСистемы</span>
<span class="logo-desc">МКД Череповец</span>
</div>
</div>
<div class="nav-links">
<a href="#services">Услуги</a>
<a href="#benefits">Преимущества</a>
<a href="#projects">Проекты</a>
<a href="#contact">Контакты</a>
</div>
<div class="nav-phone">
<a href="tel:+79001234567" class="phone-link">
<span class="phone-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg></span>
<div class="phone-info">
<span class="phone-number">+7 (900) 123-45-67</span>
<span class="phone-label">Бесплатная консультация</span>
</div>
</a>
<a href="#contact" class="btn-primary btn-nav">Получить КП</a>
</div>
<button class="burger" id="burger" aria-label="Меню">
<span></span><span></span><span></span>
</button>
</div>
</div>
</nav>
<!-- Mobile menu (fullscreen) -->
<div class="mobile-menu" id="mobileMenu">
<div class="mobile-menu-inner">
<a href="#services" class="mobile-link">
<span>Услуги</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</a>
<a href="#benefits" class="mobile-link">
<span>Преимущества</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</a>
<a href="#projects" class="mobile-link">
<span>Проекты</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</a>
<a href="#contact" class="mobile-link">
<span>Контакты</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</a>
<div class="mobile-menu-bottom">
<a href="tel:+79001234567" class="mobile-phone-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
+7 (900) 123-45-67
</a>
<a href="#contact" class="btn-primary btn-lg btn-full mobile-cta-link" style="text-decoration:none;">Получить КП</a>
</div>
</div>
</div>
<!-- Floating mobile CTA -->
<div class="mobile-float-cta" id="floatCta">
<a href="#contact" class="btn-primary btn-float">
Получить КП
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
<!-- Hero Section -->
<section class="hero" id="hero">
<div class="hero-bg">
<div class="hero-grid-pattern"></div>
<div class="hero-glow hero-glow-1"></div>
<div class="hero-glow hero-glow-2"></div>
<div class="hero-lines"></div>
</div>
<div class="container hero-container">
<div class="hero-content">
<div class="badge">
<span class="pulse"></span>
Работаем только с МКД
</div>
<h1>
Инженерные системы<br>
<span class="gradient-text">водоснабжения и канализации</span><br>
для МКД в&nbsp;Череповце
</h1>
<p class="lead">
Проектирование, капитальный ремонт и обслуживание общедомовых
инженерных систем. Работаем с&nbsp;УК, ТСЖ и&nbsp;застройщиками
Вологодской области.
</p>
<div class="hero-buttons">
<a href="#contact" class="btn-primary btn-lg">
Получить КП за 24 часа
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
<a href="tel:+79001234567" class="btn-secondary btn-lg">
<span class="btn-icon-phone"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg></span> Выезд специалиста
</a>
</div>
<div class="hero-trust">
<div class="trust-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1E6BFF" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
Работа по договору
</div>
<div class="trust-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1E6BFF" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
Прозрачная смета
</div>
<div class="trust-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1E6BFF" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
Гарантия 5 лет
</div>
</div>
</div>
<div class="hero-stats-panel">
<div class="stats-card">
<div class="stat-item-new">
<div class="stat-number-new" data-target="12">12+</div>
<div class="stat-label-new">лет на рынке</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item-new">
<div class="stat-number-new" data-target="87">87+</div>
<div class="stat-label-new">объектов МКД</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item-new">
<div class="stat-number-new" data-target="24">24+</div>
<div class="stat-label-new">партнёра УК/ТСЖ</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item-new">
<div class="stat-number-new" data-target="0">0</div>
<div class="stat-label-new">аварий после ремонта</div>
</div>
</div>
</div>
</div>
</section>
<!-- Services -->
<section class="services" id="services">
<div class="container">
<div class="section-header">
<div class="section-badge">УСЛУГИ</div>
<h2>Профессиональные решения<br>для <span class="gradient-text">общедомовых систем</span></h2>
<p class="section-subtitle">Полный цикл работ по инженерным системам МКД — от проекта до сдачи</p>
</div>
<div class="services-grid">
<div class="service-card">
<div class="service-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
</div>
<h3>Проектирование систем МКД</h3>
<p>Разработка проектной документации по СНиП и ГОСТ. Согласование с надзорными органами.</p>
<a href="#contact" class="service-link">Подробнее →</a>
</div>
<div class="service-card">
<div class="service-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div>
<h3>Капитальный ремонт ХВС/ГВС</h3>
<p>Полная замена изношенных коммуникаций. Минимальное время отключения воды.</p>
<a href="#contact" class="service-link">Подробнее →</a>
</div>
<div class="service-card">
<div class="service-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg>
</div>
<h3>Замена стояков водоснабжения</h3>
<p>Работы в действующих домах с поэтапным отключением по стоякам.</p>
<a href="#contact" class="service-link">Подробнее →</a>
</div>
<div class="service-card">
<div class="service-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<h3>Модернизация инженерных сетей</h3>
<p>Повышение надёжности, снижение аварийности и эксплуатационных расходов.</p>
<a href="#contact" class="service-link">Подробнее →</a>
</div>
</div>
</div>
</section>
<!-- Benefits -->
<section class="benefits" id="benefits">
<div class="container">
<div class="section-header">
<div class="section-badge">ПРЕИМУЩЕСТВА</div>
<h2>Почему нам доверяют<br><span class="gradient-text">УК и ТСЖ Череповца</span></h2>
</div>
<div class="benefits-grid">
<div class="benefit-card">
<div class="benefit-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5C7 4 9 7 12 7s5-3 7.5-3a2.5 2.5 0 0 1 0 5H18"/><path d="M18 9v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9"/><path d="M12 7v14"/></svg></div>
<div class="benefit-text"><h3>12+ лет в МКД</h3>
<p>Специализируемся исключительно на многоквартирных домах — знаем специфику</p></div>
</div>
<div class="benefit-card">
<div class="benefit-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div>
<div class="benefit-text"><h3>Соблюдение сроков</h3>
<p>Штрафные санкции в договоре за нарушение сроков выполнения работ</p></div>
</div>
<div class="benefit-card">
<div class="benefit-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg></div>
<div class="benefit-text"><h3>Прозрачная смета</h3>
<p>Подробная детализация всех материалов и работ — без скрытых платежей</p></div>
</div>
<div class="benefit-card">
<div class="benefit-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>
<div class="benefit-text"><h3>Работа по договору</h3>
<p>Полный пакет документов: договор, акты, исполнительная документация</p></div>
</div>
<div class="benefit-card">
<div class="benefit-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg></div>
<div class="benefit-text"><h3>Гарантия 5 лет</h3>
<p>Гарантийное обслуживание и оперативное реагирование на обращения</p></div>
</div>
<div class="benefit-card">
<div class="benefit-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg></div>
<div class="benefit-text"><h3>Минимум отключений</h3>
<p>Поэтажное отключение — жители других этажей не остаются без воды</p></div>
</div>
</div>
</div>
</section>
<!-- Projects -->
<section class="projects" id="projects">
<div class="container">
<div class="section-header">
<div class="section-badge">ПОРТФОЛИО</div>
<h2>Реализованные <span class="gradient-text">объекты в Череповце</span></h2>
<p class="section-subtitle">Каждый проект — это работа в действующем жилом доме с жильцами</p>
</div>
<div class="projects-grid">
<div class="project-card">
<div class="project-img">
<div class="project-overlay">
<span class="project-tag">Капремонт</span>
</div>
<div class="project-icon-bg">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="rgba(59,130,246,0.3)" stroke-width="1"><rect x="1" y="6" width="22" height="15" rx="2"/><path d="M1 10h22M8 6V2M16 6V2"/></svg>
</div>
</div>
<div class="project-info">
<h3>Замена стояков ХВС и ГВС</h3>
<p class="project-address">ул. Ленина, д. 42 — 9-этажный МКД</p>
<div class="project-stats">
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>152 квартиры</span>
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>28 дней</span>
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><path d="M20 6L9 17l-5-5"/></svg>Сдано</span>
</div>
</div>
</div>
<div class="project-card">
<div class="project-img">
<div class="project-overlay">
<span class="project-tag">Канализация</span>
</div>
<div class="project-icon-bg">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="rgba(59,130,246,0.3)" stroke-width="1"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/></svg>
</div>
</div>
<div class="project-info">
<h3>Капремонт канализации</h3>
<p class="project-address">ул. Металлургов, д. 15 — 5-этажный МКД</p>
<div class="project-stats">
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>80 квартир</span>
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>21 день</span>
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><path d="M20 6L9 17l-5-5"/></svg>Сдано</span>
</div>
</div>
</div>
<div class="project-card">
<div class="project-img">
<div class="project-overlay">
<span class="project-tag">Модернизация</span>
</div>
<div class="project-icon-bg">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="rgba(59,130,246,0.3)" stroke-width="1"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
</div>
<div class="project-info">
<h3>Модернизация узлов водоснабжения</h3>
<p class="project-address">мкр. Зашекснинский — комплекс из 3 домов</p>
<div class="project-stats">
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/></svg>240 квартир</span>
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>45 дней</span>
<span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" style="vertical-align:-2px;margin-right:3px"><path d="M20 6L9 17l-5-5"/></svg>Сдано</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Work Process -->
<section class="process light-section">
<div class="container">
<div class="section-header">
<div class="section-badge">КАК МЫ РАБОТАЕМ</div>
<h2 style="color:var(--text-dark)">Прозрачный процесс —<br><span class="gradient-text">от осмотра до сдачи</span></h2>
<p class="section-subtitle">Чёткая последовательность этапов, понятная для УК и ТСЖ</p>
</div>
<div class="process-grid">
<div class="process-step animate-on-scroll">
<div class="process-num">1</div>
<div>
<h3>Осмотр объекта</h3>
<p>Бесплатный выезд инженера, фиксация состояния систем, фотоотчёт</p>
</div>
</div>
<div class="process-step animate-on-scroll">
<div class="process-num">2</div>
<div>
<h3>Техническое решение</h3>
<p>Проектирование оптимального варианта с учётом особенностей дома</p>
</div>
</div>
<div class="process-step animate-on-scroll">
<div class="process-num">3</div>
<div>
<h3>Коммерческое предложение</h3>
<p>Детальная смета с расшифровкой материалов и работ за 24 часа</p>
</div>
</div>
<div class="process-step animate-on-scroll">
<div class="process-num">4</div>
<div>
<h3>Договор</h3>
<p>Фиксированная цена, сроки, гарантии и штрафные санкции</p>
</div>
</div>
<div class="process-step animate-on-scroll">
<div class="process-num">5</div>
<div>
<h3>Выполнение и сдача</h3>
<p>Работы по графику, акты КС-2/КС-3, гарантия 5 лет</p>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="cta" id="contact">
<div class="container">
<div class="cta-box">
<div class="cta-left">
<h2>Получите расчёт стоимости<br><span class="gradient-text">для вашего МКД</span></h2>
<p>Подготовим коммерческое предложение с техническим решением и сметой за 24 часа</p>
<div class="cta-features">
<div class="cta-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#1E6BFF" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
Бесплатный выезд специалиста
</div>
<div class="cta-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#1E6BFF" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
КП готово за 24 часа
</div>
<div class="cta-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#1E6BFF" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
Без обязательств
</div>
</div>
</div>
<form class="contact-form" id="leadForm" onsubmit="submitLead(event)">
<input type="text" id="f-company" placeholder="Название организации (УК / ТСЖ)" required autocomplete="organization">
<div class="form-row">
<input type="text" id="f-name" placeholder="Ваше имя" required autocomplete="name">
<input type="tel" id="f-phone" placeholder="+7 (___) ___-__-__" required autocomplete="tel" inputmode="tel">
</div>
<textarea id="f-message" placeholder="Адрес дома, этажность, что нужно сделать" rows="3"></textarea>
<button type="submit" class="btn-primary btn-lg btn-full" id="submitBtn">
Отправить заявку
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
<p class="form-disclaimer">Нажимая кнопку, вы соглашаетесь с обработкой персональных данных</p>
</form>
<div id="formSuccess" style="display:none;text-align:center;padding:2rem;background:rgba(30,107,255,.06);border:1px solid rgba(30,107,255,.15);border-radius:12px;color:#1E6BFF;font-weight:600;font-size:1.05rem">
✓ Заявка отправлена!<br><span style="font-weight:400;font-size:.9rem;color:#64748B">Мы свяжемся с вами в ближайшее время</span>
</div>
</div>
</div>
</section>
<!-- Bottom CTA -->
<section class="bottom-cta">
<div class="container">
<h2>Получите расчёт для вашего дома<br><span class="gradient-text">в Череповце</span></h2>
<p>Бесплатный выезд инженера и коммерческое предложение за 24 часа — без обязательств</p>
<a href="#contact" class="btn-primary btn-lg">
Получить коммерческое предложение
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</a>
</div>
</section>
<!-- Footer -->
<footer>
<div class="container">
<div class="footer-grid">
<div>
<div class="logo" style="margin-bottom: 1rem;">
<div class="logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
</svg>
</div>
<div class="logo-text">
<span class="logo-name">ИнженерСистемы</span>
</div>
</div>
<p>Проектирование, ремонт и обслуживание инженерных систем для многоквартирных домов Череповца</p>
</div>
<div>
<h4>Контакты</h4>
<p><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" style="vertical-align:-2px;margin-right:4px"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg><a href="tel:+79001234567" style="color:#94a3b8;">+7 (900) 123-45-67</a></p>
<p><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" style="vertical-align:-2px;margin-right:4px"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 7l-10 7L2 7"/></svg><a href="mailto:info@ingener-mkd.ru" style="color:#94a3b8;">info@ingener-mkd.ru</a></p>
<p><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" style="vertical-align:-2px;margin-right:4px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>г. Череповец, Вологодская обл.</p>
</div>
<div>
<h4>Документы</h4>
<p>ИНН: XXXXXXXXXX</p>
<p>ОГРН: XXXXXXXXXXXXX</p>
<p>СРО: Членство в саморегулируемой организации</p>
</div>
</div>
<div class="footer-bottom">
© 2026 ИнженерСистемы. Все права защищены.
</div>
</div>
</footer>
<script>
// Sticky nav + hide top bar + floating CTA
const topBar = document.querySelector('.top-bar');
const floatCta = document.getElementById('floatCta');
let lastScrollY = 0;
window.addEventListener('scroll', () => {
const nav = document.getElementById('nav');
const y = window.scrollY;
if (y > 50) {
nav.classList.add('scrolled');
if (topBar) topBar.classList.add('hidden');
} else {
nav.classList.remove('scrolled');
if (topBar) topBar.classList.remove('hidden');
}
// Show floating CTA after hero, hide near form
if (floatCta) {
const contactSection = document.getElementById('contact');
const contactTop = contactSection ? contactSection.getBoundingClientRect().top : 9999;
if (y > 500 && contactTop > 300) {
floatCta.classList.add('visible');
} else {
floatCta.classList.remove('visible');
}
}
lastScrollY = y;
});
// Burger menu
const burger = document.getElementById('burger');
const mobileMenu = document.getElementById('mobileMenu');
burger.addEventListener('click', () => {
burger.classList.toggle('active');
mobileMenu.classList.toggle('open');
document.body.classList.toggle('menu-open');
});
// Close mobile menu on link click
document.querySelectorAll('.mobile-link, .mobile-cta-link').forEach(link => {
link.addEventListener('click', () => {
burger.classList.remove('active');
mobileMenu.classList.remove('open');
document.body.classList.remove('menu-open');
});
});
// Smooth scroll
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
// Scroll animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.service-card, .benefit-card, .project-card, .stat-item-new, .process-step').forEach(el => {
el.classList.add('animate-on-scroll');
observer.observe(el);
});
// Counter animation
const counterObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const counters = entry.target.querySelectorAll('.stat-number-new');
counters.forEach(counter => {
const target = parseInt(counter.getAttribute('data-target'));
const suffix = counter.textContent.includes('+') ? '+' : '';
let current = 0;
const step = Math.max(1, Math.ceil(target / 40));
const timer = setInterval(() => {
current += step;
if (current >= target) {
current = target;
clearInterval(timer);
}
counter.textContent = current + suffix;
}, 30);
});
counterObserver.unobserve(entry.target);
}
});
}, { threshold: 0.5 });
const statsCard = document.querySelector('.stats-card');
if (statsCard) counterObserver.observe(statsCard);
// === Mobile Carousels ===
function initCarousel(gridSelector, autoplayMs) {
const grid = document.querySelector(gridSelector);
if (!grid || window.innerWidth > 639) return;
const cards = grid.children;
if (cards.length < 2) return;
function getCardWidth() {
return cards[0].offsetWidth + 14; // card width + gap
}
function scrollToIdx(idx) {
grid.scrollTo({ left: idx * getCardWidth(), behavior: 'smooth' });
}
// Create dots
const dotsWrap = document.createElement('div');
dotsWrap.className = 'carousel-dots';
for (let i = 0; i < cards.length; i++) {
const dot = document.createElement('div');
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
dot.addEventListener('click', () => scrollToIdx(i));
dotsWrap.appendChild(dot);
}
grid.parentNode.insertBefore(dotsWrap, grid.nextSibling);
// Update dots on scroll
let currentIdx = 0;
grid.addEventListener('scroll', () => {
const idx = Math.round(grid.scrollLeft / getCardWidth());
if (idx !== currentIdx && idx >= 0 && idx < cards.length) {
currentIdx = idx;
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, i) => {
d.classList.toggle('active', i === idx);
});
}
});
// Autoplay
let autoTimer;
let paused = false;
function startAuto() {
autoTimer = setInterval(() => {
if (paused) return;
currentIdx = (currentIdx + 1) % cards.length;
scrollToIdx(currentIdx);
}, autoplayMs);
}
// Pause on touch
grid.addEventListener('touchstart', () => { paused = true; });
grid.addEventListener('touchend', () => {
paused = false;
clearInterval(autoTimer);
startAuto();
});
startAuto();
}
initCarousel('.services-grid', 4000);
initCarousel('.benefits-grid', 3500);
initCarousel('.projects-grid', 5000);
// === Submit lead form ===
async function submitLead(e) {
e.preventDefault();
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = 'Отправка...';
try {
const r = await fetch('/api/submit-lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company: document.getElementById('f-company').value,
name: document.getElementById('f-name').value,
phone: document.getElementById('f-phone').value,
message: document.getElementById('f-message').value,
})
});
if (r.ok) {
document.getElementById('leadForm').style.display = 'none';
document.getElementById('formSuccess').style.display = 'block';
}
} catch(err) {
alert('Ошибка отправки, попробуйте позвонить нам');
}
btn.disabled = false;
btn.textContent = 'Отправить заявку';
}
// === Phone mask ===
const phoneInput = document.getElementById('f-phone');
if (phoneInput) {
phoneInput.addEventListener('input', function(e) {
let v = this.value.replace(/\D/g, '');
if (v.startsWith('8')) v = '7' + v.slice(1);
if (v.startsWith('7')) v = v.slice(1);
let r = '+7';
if (v.length > 0) r += ' (' + v.substring(0,3);
if (v.length >= 3) r += ') ' + v.substring(3,6);
if (v.length >= 6) r += '-' + v.substring(6,8);
if (v.length >= 8) r += '-' + v.substring(8,10);
this.value = r;
});
phoneInput.addEventListener('focus', function() {
if (!this.value) this.value = '+7 (';
});
}
</script>
</body>
</html>
+1098
View File
File diff suppressed because it is too large Load Diff
View File
+273
View File
@@ -0,0 +1,273 @@
const express = require('express');
const { Pool } = require('pg');
const session = require('express-session');
const bcrypt = require('bcryptjs');
const path = require('path');
const https = require('https');
const multer = require('multer');
const app = express();
const PORT = 3006;
// Multer config
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, 'public/uploads')),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, 'project-' + Date.now() + ext);
}
});
const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (req, file, cb) => {
const ok = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.originalname);
cb(null, ok);
}});
// DB
const pool = new Pool({
user: 'postgres',
host: 'localhost',
database: 'voda_landing',
password: 'postgres',
port: 5432,
});
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
secret: 'voda-landing-secret-' + Date.now(),
resave: false,
saveUninitialized: false,
cookie: { maxAge: 24 * 60 * 60 * 1000 }
}));
// Static
app.use('/admin', express.static(path.join(__dirname, 'admin')));
app.use(express.static('public'));
// Admin password (default: admin)
const ADMIN_HASH = bcrypt.hashSync('admin', 10);
// Auth middleware
function requireAuth(req, res, next) {
if (req.session && req.session.admin) return next();
res.status(401).json({ error: 'Unauthorized' });
}
// === AUTH ===
app.post('/api/login', async (req, res) => {
const { password } = req.body;
// Check DB for admin password, fallback to default
const r = await pool.query("SELECT value FROM settings WHERE key='admin_password'");
const hash = r.rows.length ? r.rows[0].value : ADMIN_HASH;
if (bcrypt.compareSync(password || '', hash)) {
req.session.admin = true;
res.json({ ok: true });
} else {
res.status(401).json({ error: 'Wrong password' });
}
});
app.post('/api/logout', (req, res) => {
req.session.destroy();
res.json({ ok: true });
});
app.get('/api/auth-check', (req, res) => {
res.json({ auth: !!(req.session && req.session.admin) });
});
// === SETTINGS ===
app.get('/api/settings', requireAuth, async (req, res) => {
const r = await pool.query('SELECT key, value FROM settings');
const obj = {};
r.rows.forEach(row => obj[row.key] = row.value);
res.json(obj);
});
app.put('/api/settings', requireAuth, async (req, res) => {
const entries = Object.entries(req.body);
for (const [key, value] of entries) {
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()`,
[key, value]
);
}
res.json({ ok: true });
});
// === SERVICES ===
app.get('/api/services', requireAuth, async (req, res) => {
const r = await pool.query('SELECT * FROM services ORDER BY sort_order');
res.json(r.rows);
});
app.post('/api/services', requireAuth, async (req, res) => {
const { title, description, icon_svg, sort_order } = req.body;
const r = await pool.query(
'INSERT INTO services (title, description, icon_svg, sort_order) VALUES ($1,$2,$3,$4) RETURNING *',
[title, description, icon_svg || '', sort_order || 0]
);
res.json(r.rows[0]);
});
app.put('/api/services/:id', requireAuth, async (req, res) => {
const { title, description, icon_svg, sort_order, active } = req.body;
const r = await pool.query(
'UPDATE services SET title=$1, description=$2, icon_svg=$3, sort_order=$4, active=$5 WHERE id=$6 RETURNING *',
[title, description, icon_svg, sort_order, active, req.params.id]
);
res.json(r.rows[0]);
});
app.delete('/api/services/:id', requireAuth, async (req, res) => {
await pool.query('DELETE FROM services WHERE id=$1', [req.params.id]);
res.json({ ok: true });
});
// === BENEFITS ===
app.get('/api/benefits', requireAuth, async (req, res) => {
const r = await pool.query('SELECT * FROM benefits ORDER BY sort_order');
res.json(r.rows);
});
app.post('/api/benefits', requireAuth, async (req, res) => {
const { title, description, icon_svg, sort_order } = req.body;
const r = await pool.query(
'INSERT INTO benefits (title, description, icon_svg, sort_order) VALUES ($1,$2,$3,$4) RETURNING *',
[title, description, icon_svg || '', sort_order || 0]
);
res.json(r.rows[0]);
});
app.put('/api/benefits/:id', requireAuth, async (req, res) => {
const { title, description, icon_svg, sort_order, active } = req.body;
const r = await pool.query(
'UPDATE benefits SET title=$1, description=$2, icon_svg=$3, sort_order=$4, active=$5 WHERE id=$6 RETURNING *',
[title, description, icon_svg, sort_order, active, req.params.id]
);
res.json(r.rows[0]);
});
app.delete('/api/benefits/:id', requireAuth, async (req, res) => {
await pool.query('DELETE FROM benefits WHERE id=$1', [req.params.id]);
res.json({ ok: true });
});
// === UPLOAD ===
app.post('/api/upload', requireAuth, upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
res.json({ url: '/uploads/' + req.file.filename });
});
// === PROJECTS ===
app.get('/api/projects', requireAuth, async (req, res) => {
const r = await pool.query('SELECT * FROM projects ORDER BY sort_order');
res.json(r.rows);
});
app.post('/api/projects', requireAuth, async (req, res) => {
const { title, address, tag, apartments, duration, status, icon_svg, sort_order, image_url } = req.body;
const r = await pool.query(
'INSERT INTO projects (title, address, tag, apartments, duration, status, icon_svg, sort_order, image_url) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *',
[title, address, tag, apartments, duration, status || 'Сдано', icon_svg || '', sort_order || 0, image_url || '']
);
res.json(r.rows[0]);
});
app.put('/api/projects/:id', requireAuth, async (req, res) => {
const { title, address, tag, apartments, duration, status, icon_svg, sort_order, active, image_url } = req.body;
const r = await pool.query(
'UPDATE projects SET title=$1, address=$2, tag=$3, apartments=$4, duration=$5, status=$6, icon_svg=$7, sort_order=$8, active=$9, image_url=$10 WHERE id=$11 RETURNING *',
[title, address, tag, apartments, duration, status, icon_svg, sort_order, active, image_url || '', req.params.id]
);
res.json(r.rows[0]);
});
app.delete('/api/projects/:id', requireAuth, async (req, res) => {
await pool.query('DELETE FROM projects WHERE id=$1', [req.params.id]);
res.json({ ok: true });
});
// === LEADS ===
app.get('/api/leads', requireAuth, async (req, res) => {
const r = await pool.query('SELECT * FROM leads ORDER BY created_at DESC');
res.json(r.rows);
});
app.put('/api/leads/:id', requireAuth, async (req, res) => {
const { status } = req.body;
const r = await pool.query(
'UPDATE leads SET status=$1, updated_at=NOW() WHERE id=$2 RETURNING *',
[status, req.params.id]
);
res.json(r.rows[0]);
});
app.delete('/api/leads/:id', requireAuth, async (req, res) => {
await pool.query('DELETE FROM leads WHERE id=$1', [req.params.id]);
res.json({ ok: true });
});
// === PUBLIC: Submit lead ===
app.post('/api/submit-lead', async (req, res) => {
const { company, name, phone, message } = req.body;
if (!phone) return res.status(400).json({ error: 'Phone required' });
const r = await pool.query(
'INSERT INTO leads (company, name, phone, message) VALUES ($1,$2,$3,$4) RETURNING *',
[company || '', name || '', phone, message || '']
);
// Telegram notification
try {
const tgR = await pool.query("SELECT key, value FROM settings WHERE key IN ('tg_bot_token','tg_chat_id','tg_enabled')");
const tg = {};
tgR.rows.forEach(row => tg[row.key] = row.value);
if (tg.tg_enabled === 'true' && tg.tg_bot_token && tg.tg_chat_id) {
const text = `🔔 Новая заявка!\n\n🏢 ${company || '—'}\n👤 ${name || '—'}\n📞 ${phone}\n💬 ${message || '—'}`;
const url = `https://api.telegram.org/bot${tg.tg_bot_token}/sendMessage`;
const data = JSON.stringify({ chat_id: tg.tg_chat_id, text, parse_mode: 'HTML' });
const req2 = https.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
req2.write(data);
req2.end();
}
} catch (e) {
console.error('TG error:', e.message);
}
res.json({ ok: true, id: r.rows[0].id });
});
// === PUBLIC: Get landing data ===
app.get('/api/landing-data', async (req, res) => {
const [settingsR, servicesR, benefitsR, projectsR] = await Promise.all([
pool.query('SELECT key, value FROM settings'),
pool.query('SELECT * FROM services WHERE active=true ORDER BY sort_order'),
pool.query('SELECT * FROM benefits WHERE active=true ORDER BY sort_order'),
pool.query('SELECT * FROM projects WHERE active=true ORDER BY sort_order'),
]);
const settings = {};
settingsR.rows.forEach(row => settings[row.key] = row.value);
res.json({ settings, services: servicesR.rows, benefits: benefitsR.rows, projects: projectsR.rows });
});
// === CHANGE PASSWORD ===
app.put('/api/change-password', requireAuth, async (req, res) => {
const { password } = req.body;
if (!password || password.length < 4) return res.status(400).json({ error: 'Min 4 chars' });
const hash = bcrypt.hashSync(password, 10);
await pool.query(
`INSERT INTO settings (key, value, updated_at) VALUES ('admin_password', $1, NOW())
ON CONFLICT (key) DO UPDATE SET value=$1, updated_at=NOW()`,
[hash]
);
res.json({ ok: true });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});