Initial commit: Voda Landing (Cherepovets)
This commit is contained in:
+29
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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,"'")})'>✎</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,"'")})'>✎</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,"'")})'>✎</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
// Init
|
||||||
|
checkAuth();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1168
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>
|
||||||
|
для МКД в Череповце
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="lead">
|
||||||
|
Проектирование, капитальный ремонт и обслуживание общедомовых
|
||||||
|
инженерных систем. Работаем с УК, ТСЖ и застройщиками
|
||||||
|
Вологодской области.
|
||||||
|
</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
File diff suppressed because it is too large
Load Diff
@@ -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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user