Обновление проекта

This commit is contained in:
2026-07-16 01:20:11 +05:00
parent d6c939fd15
commit a438ceeccb
11 changed files with 137 additions and 153 deletions

View File

@@ -1,19 +1,22 @@
# Hotel 777 - Environment Configuration
# Copy this file to .env and fill in your values
# Copy this file to .env and fill in your values: cp .env.example .env
#
# ⚠️ HOTEL777KEY и JWT_SECRET обязательны - без них сервер не запустится!
# ⚠️ JWT_SECRET должен быть не менее 32 символов
# API Key for external access (required)
# API-ключ для внешнего доступа к бронированиям (обязателен)
HOTEL777KEY=your-secret-api-key-here
# JWT Secret for token signing (required) - minimum 32 characters
# Секретный ключ для подписи JWT токенов (обязателен) — минимум 32 символа
JWT_SECRET=your-jwt-secret-min-32-characters-here
# Admin credentials (required for first run)
# Учетные данные суперадмина (нужны для первого запуска)
ADMIN_LOGIN=admin
ADMIN_PASSWORD=your-admin-password
# Monitoring credentials (optional)
# Мониторинг Prometheus /metrics (опционально, значения по умолчанию указаны)
MONITORING_USER=monitoring
MONITORING_PASSWORD=monitoring123
# Server port (optional, default: 3000)
# PORT=3000
# Порт сервера (опционально, по умолчанию 3000)
PORT=3000

19
.gitea/workflows/run.yml Normal file
View File

@@ -0,0 +1,19 @@
name: Deploy hotel
# Триггеры запуска
on:
# push:
# branches:
- main # автоматический запуск при пуше в main
# workflow_dispatch: # ручной запуск через интерфейс Gitea (кнопка "Run workflow")
jobs:
deploy-kdo:
runs-on: kdo
steps:
- name: clear zhelcrm
run: docker system prune -af
- name: Deploy
run: |
cd /docker/hotell777_260507 && docker compose build --no-cache && docker compose up -d
#

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
package-lock.json
.env
/data/
promt
promt
*.log

View File

@@ -1,5 +1,3 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const config = require('../../config');
const promocodeRateLimit = new Map();
@@ -34,11 +32,9 @@ setInterval(() => {
}, PROMOCODE_WINDOW);
let db;
let JWT_SECRET;
function init(database, jwtSecret) {
function init(database) {
db = database;
JWT_SECRET = jwtSecret;
}
function validatePromocode(promocode, callback) {
@@ -299,11 +295,11 @@ function updateBookingDetails(req, res) {
if (!roomType) {
return finishUpdate();
}
db.get(`SELECT price_per_guest FROM rooms WHERE type = ? AND is_active = 1`, [roomType], (err, roomData) => {
db.get(`SELECT price_per_night FROM rooms WHERE type = ? AND is_active = 1`, [roomType], (err, roomData) => {
if (err) return res.status(500).json({ error: 'Database error' });
const pricePerGuest = roomData ? roomData.price_per_guest : config.getRoomPrice(roomType);
const pricePerNight = roomData ? roomData.price_per_night : config.getRoomPrice(roomType);
const nights = config.calculateNights(finalCheckin, finalCheckout);
const basePrice = pricePerGuest * totalGuests * nights;
const basePrice = pricePerNight * totalGuests * nights;
const discountPercent = row.discount_percent || 0;
const discountAmount = Math.round(basePrice * discountPercent / 100);
const totalPrice = basePrice - discountAmount;

View File

@@ -10,35 +10,37 @@
<style>
:root { --primary: #2563eb; --gold: #c9a84c; --dark: #0f172a; --sidebar-width: 260px; }
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; font-size: clamp(0.85rem, 1.5vw, 0.95rem); }
#login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #0f172a, #1e293b); }
.login-card { background: #fff; border-radius: 16px; padding: 48px 40px; width: 100%; max-width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.login-card h2 { font-family: 'Playfair Display', serif; text-align: center; margin-bottom: 8px; color: var(--dark); }
.login-card { background: #fff; border-radius: 16px; padding: clamp(24px, 6vw, 48px) clamp(16px, 5vw, 40px); width: 100%; max-width: min(90vw, 400px); box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.login-card h2 { font-family: 'Playfair Display', serif; text-align: center; margin-bottom: 8px; color: var(--dark); font-size: clamp(1.2rem, 2.5vw, 1.5rem); }
.login-card .subtitle { text-align: center; color: #64748b; margin-bottom: 32px; font-size: 0.9rem; }
.login-card .form-label { font-weight: 500; font-size: 0.85rem; color: #334155; }
.login-card .form-control { border-radius: 10px; padding: 12px 16px; border: 1px solid #e2e8f0; }
.login-card .form-control { border-radius: 10px; padding: clamp(8px, 1.5vw, 12px) clamp(12px, 2vw, 16px); border: 1px solid #e2e8f0; font-size: clamp(0.85rem, 1.3vw, 0.95rem); }
.login-card .form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.login-btn { width: 100%; padding: 14px; border: none; border-radius: 10px; background: var(--primary); color: #fff; font-weight: 600; font-size: 1rem; cursor: pointer; margin-top: 8px; }
.login-btn { width: 100%; padding: clamp(10px, 2vw, 14px); border: none; border-radius: 10px; background: var(--primary); color: #fff; font-weight: 600; font-size: 1rem; cursor: pointer; margin-top: 8px; }
.login-btn:hover { background: #1d4ed8; }
.login-error { color: #ef4444; font-size: 0.85rem; text-align: center; margin-top: 12px; display: none; }
.login-logo { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--gold); margin-bottom: 24px; }
.login-logo span { color: var(--primary); }
#admin-panel { display: none; }
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: var(--sidebar-width); background: var(--dark); color: #fff; padding: 24px 0; z-index: 100; overflow-y: auto; }
.hamburger { display: none; position: fixed; top: 12px; left: 12px; z-index: 300; width: 40px; height: 40px; border-radius: 10px; background: var(--dark); color: #fff; border: none; font-size: 1.25rem; cursor: pointer; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: var(--sidebar-width); background: var(--dark); color: #fff; padding: 24px 0; z-index: 100; overflow-y: auto; transition: transform 0.3s ease; }
.sidebar-brand { padding: 0 24px; font-size: 1.25rem; font-weight: 700; color: var(--gold); margin-bottom: 32px; }
.sidebar-brand span { color: #fff; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 12px 24px; color: #94a3b8; text-decoration: none; font-size: 0.9rem; transition: 0.2s; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 12px 24px; color: #94a3b8; text-decoration: none; font-size: clamp(0.8rem, 1.2vw, 0.9rem); transition: 0.2s; }
.sidebar-nav a:hover, .sidebar-nav a.active { background: rgba(255,255,255,0.05); color: #fff; border-right: 3px solid var(--gold); }
.sidebar-nav a i { width: 20px; text-align: center; }
.sidebar-user { position: absolute; bottom: 0; left: 0; right: 0; padding: 20px 24px; border-top: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.2); }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
.sidebar-user { padding: 20px 24px; border-top: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.2); }
.sidebar-user .name { font-weight: 600; font-size: 0.9rem; }
.sidebar-user .role { font-size: 0.75rem; color: #94a3b8; }
.main-content { margin-left: var(--sidebar-width); padding: 32px; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.top-bar h1 { font-size: 1.75rem; font-weight: 700; color: var(--dark); margin: 0; }
.main-content { margin-left: var(--sidebar-width); padding: clamp(16px, 3vw, 32px); }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; gap: clamp(8px, 2vw, 16px); flex-wrap: wrap; }
.top-bar h1 { font-size: clamp(1.25rem, 2.5vw, 1.75rem); font-weight: 700; color: var(--dark); margin: 0; }
.card { background: #fff; border-radius: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: none; margin-bottom: 24px; }
.card-header-custom { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
@@ -121,7 +123,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
.tab-content { display: none; }
.tab-content.active { display: block; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 45vw), 1fr)); gap: clamp(10px, 2vw, 20px); margin-bottom: 32px; }
.stat-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-card .stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; margin-bottom: 12px; }
.stat-card .stat-icon.blue { background: #dbeafe; color: #2563eb; }
@@ -130,8 +132,8 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
.stat-card .stat-value { font-size: 1.75rem; font-weight: 700; color: var(--dark); }
.stat-card .stat-label { font-size: 0.8rem; color: #64748b; }
.toast-container { position: fixed; top: 24px; right: 24px; z-index: 999; }
.toast { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); margin-bottom: 12px; display: flex; align-items: center; gap: 12px; animation: slideIn 0.3s ease; min-width: 300px; }
.toast-container { position: fixed; top: 24px; right: clamp(8px, 3vw, 24px); left: clamp(8px, 3vw, auto); z-index: 999; }
.toast { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); margin-bottom: 12px; display: flex; align-items: center; gap: 12px; animation: slideIn 0.3s ease; min-width: min(90vw, 300px); max-width: min(95vw, 400px); }
.toast.success { border-left: 4px solid #16a34a; }
.toast.error { border-left: 4px solid #ef4444; }
.toast .toast-icon { font-size: 1.25rem; }
@@ -143,8 +145,29 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.sidebar { display: none; }
.main-content { margin-left: 0; }
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.show { display: block; }
.hamburger { display: flex; }
.main-content { margin-left: 0; padding: 16px; padding-top: 60px; }
.top-bar { flex-direction: column; align-items: flex-start; }
.filter-bar { flex-direction: column; align-items: stretch; }
.filter-bar input, .filter-bar select { width: 100% !important; }
.modal-custom { max-width: 95vw !important; margin: 0 8px; }
#roomsGrid { grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr)) !important; }
.room-modal-grid-4col { grid-template-columns: 1fr 1fr !important; }
.room-modal-grid-3col { grid-template-columns: 1fr 1fr !important; }
.room-modal-grid-2col { grid-template-columns: 1fr !important; }
}
@media (max-width: 480px) {
.login-card { padding: 24px 16px; }
.stats-row { grid-template-columns: 1fr 1fr; }
.card-body-custom { padding: 12px; }
.table { font-size: 0.75rem; }
.table th, .table td { padding: 6px 4px; }
#roomForm .mb-3 { margin-bottom: 8px; }
.room-modal-grid-4col { grid-template-columns: 1fr !important; }
.room-modal-grid-3col { grid-template-columns: 1fr !important; }
}
</style>
</head>
@@ -171,7 +194,9 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
<div id="admin-panel">
<div class="sidebar">
<button class="hamburger" id="hamburgerBtn" onclick="toggleSidebar()" title="Меню"><i class="fas fa-bars"></i></button>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebar()"></div>
<div class="sidebar" id="sidebar">
<div class="sidebar-brand">HOTEL <span>777</span></div>
<nav class="sidebar-nav">
<a href="#" class="active" data-tab="dashboard"><i class="fas fa-chart-pie"></i> Дашборд</a>
@@ -260,7 +285,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
<div class="card-body-custom">
<div class="filter-bar">
<input type="text" id="searchBookings" class="form-control" style="width: 200px; font-size: 0.85rem;" placeholder="Поиск по имени/телефону..." onkeyup="debounceSearch()">
<input type="text" id="searchBookings" class="form-control" style="max-width: 100%; width: 100%; font-size: 0.85rem;" placeholder="Поиск по имени/телефону..." onkeyup="debounceSearch()">
<span class="filter-label">Фильтр:</span>
<select id="filterStatus"><option value="all">Все статусы</option><option value="новая">Новая</option><option value="оплачена">Оплачена</option><option value="зарезервирована">Зарезервирована</option><option value="заселена">Заселена</option><option value="выехала">Выехала</option><option value="отменена">Отменена</option></select>
<select id="filterUrgent"><option value="all">Все записи</option><option value="checkin-soon">Заезд ≤ 3 дней</option><option value="checkout-today">Выезд сегодня</option></select>
@@ -293,7 +318,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
<div class="card">
<div class="card-body-custom">
<div id="roomsGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;"></div>
<div id="roomsGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
@@ -636,6 +661,16 @@ function initTabs() {
});
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('show');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebarOverlay').classList.remove('show');
}
function updateUI() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.admin-only, .admin-only-field').forEach(el => {
@@ -888,16 +923,16 @@ function renderBookings() {
'<option value="' + room.id + '"' + (r.room_id === room.id ? ' selected' : '') + '>' + room.type + ' — ' + room.name + '</option>'
).join('');
const commentHtml = '<input type="text" class="form-control form-control-sm" style="width: 120px; font-size: 0.8rem;" value="' + esc(r.comment || '') + '" onchange="changeComment(' + r.id + ', this.value)" placeholder="Комментарий">';
const discountHtml = '<input type="number" min="0" max="99" class="form-control form-control-sm" style="width: 70px; font-size: 0.8rem;" value="' + (r.discount_percent || 0) + '" onchange="changeDiscount(' + r.id + ', this.value)">';
const commentHtml = '<input type="text" class="form-control form-control-sm" style="max-width: 100%; min-width: 80px; font-size: 0.8rem;" value="' + esc(r.comment || '') + '" onchange="changeComment(' + r.id + ', this.value)" placeholder="Комментарий">';
const discountHtml = '<input type="number" min="0" max="99" class="form-control form-control-sm" style="max-width: 80px; min-width: 60px; font-size: 0.8rem;" value="' + (r.discount_percent || 0) + '" onchange="changeDiscount(' + r.id + ', this.value)">';
return '<tr class="' + rowClass + '">' +
'<td><strong>' + esc(r.name) + '</strong></td><td>' + esc(r.phone) + '</td>' +
'<td><select class="form-select form-select-sm room-select" style="width: 160px; font-size: 0.75rem;" data-booking-id="' + r.id + '" onchange="changeRoom(' + r.id + ', this.value)">' + roomOpts + '</select></td>' +
'<td><input type="number" min="0" class="form-control form-control-sm" style="width:60px; font-size:0.8rem;" value="' + r.adults + '" onchange="changeDetails(' + r.id + ', \'adults\', this.value)"></td>' +
'<td><input type="number" min="0" class="form-control form-control-sm" style="width:60px; font-size:0.8rem;" value="' + r.children + '" onchange="changeDetails(' + r.id + ', \'children\', this.value)"></td>' +
'<td><input type="date" class="form-control form-control-sm" style="width:130px; font-size:0.8rem;" value="' + esc(r.checkin_date) + '" onchange="changeDetails(' + r.id + ', \'checkin_date\', this.value)"></td>' +
'<td><input type="date" class="form-control form-control-sm" style="width:130px; font-size:0.8rem;" value="' + esc(r.checkout_date) + '" onchange="changeDetails(' + r.id + ', \'checkout_date\', this.value)"></td>' +
'<td><select class="form-select form-select-sm room-select" style="max-width: 100%; min-width: 120px; font-size: 0.75rem;" data-booking-id="' + r.id + '" onchange="changeRoom(' + r.id + ', this.value)">' + roomOpts + '</select></td>' +
'<td><input type="number" min="0" class="form-control form-control-sm" style="max-width: 60px; font-size:0.8rem;" value="' + r.adults + '" onchange="changeDetails(' + r.id + ', \'adults\', this.value)"></td>' +
'<td><input type="number" min="0" class="form-control form-control-sm" style="max-width: 60px; font-size:0.8rem;" value="' + r.children + '" onchange="changeDetails(' + r.id + ', \'children\', this.value)"></td>' +
'<td><input type="date" class="form-control form-control-sm" style="max-width: 100%; min-width: 110px; font-size:0.8rem;" value="' + esc(r.checkin_date) + '" onchange="changeDetails(' + r.id + ', \'checkin_date\', this.value)"></td>' +
'<td><input type="date" class="form-control form-control-sm" style="max-width: 100%; min-width: 110px; font-size:0.8rem;" value="' + esc(r.checkout_date) + '" onchange="changeDetails(' + r.id + ', \'checkout_date\', this.value)"></td>' +
'<td>' + esc(r.wishes || '—') + '</td>' +
'<td>' + commentHtml + '</td>' +
'<td>' + (r.base_price || 0) + '</td>' +
@@ -906,7 +941,7 @@ function renderBookings() {
'<td><strong>' + (r.total_price || r.base_price || 0) + '</strong></td>' +
'<td><span class="badge bg-info" style="font-size: 0.7rem;">' + esc(r.promocode_code || '—') + '</span></td>' +
'<td><span class="badge badge-status badge-status-' + r.status + '">' + r.status + '</span></td>' +
'<td style="white-space: nowrap;"><select class="form-select form-select-sm" style="width: 130px; font-size: 0.8rem;" onchange="changeStatus(' + r.id + ', this.value)">' + selectOpts + '</select> <button class="btn btn-outline-secondary btn-sm" style="padding: 2px 8px; font-size: 0.7rem; margin-left: 4px;" onclick="showHistory(' + r.id + ')"><i class="fas fa-clock"></i></button></td>' +
'<td style="white-space: nowrap;"><select class="form-select form-select-sm" style="max-width: 100%; min-width: 120px; font-size: 0.8rem;" onchange="changeStatus(' + r.id + ', this.value)">' + selectOpts + '</select> <button class="btn btn-outline-secondary btn-sm" style="padding: 2px 8px; font-size: 0.7rem; margin-left: 4px;" onclick="showHistory(' + r.id + ')"><i class="fas fa-clock"></i></button></td>' +
'</tr>';
}).join('');
}
@@ -1713,14 +1748,14 @@ async function uploadRoomImage() {
<!-- Room Edit Modal -->
<div class="modal-backdrop-custom" id="roomModal">
<div class="modal-custom" style="max-width: 700px;">
<div class="modal-custom" style="max-width: min(95vw, 700px);">
<div class="modal-header-custom">
<h3 id="roomModalTitle">Добавить номер</h3>
<button class="modal-close" onclick="closeRoomModal()">&times;</button>
</div>
<form id="roomForm">
<div class="modal-body-custom" style="max-height: 70vh; overflow-y: auto;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="room-modal-grid-2col" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="mb-3">
<label class="form-label">Название *</label>
<input type="text" class="form-control" id="roomName" required placeholder="2x-местный 1">
@@ -1739,7 +1774,7 @@ async function uploadRoomImage() {
<label class="form-label">Описание</label>
<textarea class="form-control" id="roomDescription" rows="3" placeholder="Описание номера..."></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div class="room-modal-grid-4col" style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div class="mb-3">
<label class="form-label">Цена/ночь (₽) *</label>
<input type="number" class="form-control" id="roomPrice" required min="0" placeholder="1500">
@@ -1757,7 +1792,7 @@ async function uploadRoomImage() {
<input type="number" class="form-control" id="roomCount" min="1" max="50" placeholder="1">
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div class="room-modal-grid-3col" style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div class="mb-3">
<label class="form-label">Этажи</label>
<input type="text" class="form-control" id="roomFloors" placeholder="1, 2, 3">
@@ -1774,7 +1809,7 @@ async function uploadRoomImage() {
<div class="mb-3">
<label class="form-label">Изображение</label>
<div style="display: flex; gap: 12px; align-items: flex-start;">
<img id="roomImagePreview" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 120'%3E%3Crect fill='%23274151' width='200' height='120'/%3E%3Ctext fill='%2364748b' font-family='sans-serif' font-size='12' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EВыберите фото%3C/text%3E%3C/svg%3E" style="width: 160px; height: 96px; object-fit: cover; border-radius: 8px; border: 1px solid #e2e8f0;">
<img id="roomImagePreview" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 120'%3E%3Crect fill='%23274151' width='200' height='120'/%3E%3Ctext fill='%2364748b' font-family='sans-serif' font-size='12' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3EВыберите фото%3C/text%3E%3C/svg%3E" style="width: min(160px, 40vw); height: auto; aspect-ratio: 5/3; object-fit: cover; border-radius: 8px; border: 1px solid #e2e8f0;">
<div style="flex: 1;">
<input type="file" class="form-control" id="roomImageInput" accept="image/*" style="font-size: 0.85rem;">
<button type="button" class="btn btn-primary btn-sm mt-2" onclick="uploadRoomImage()" style="width: 100%;"><i class="fas fa-upload"></i> Загрузить</button>
@@ -1784,7 +1819,7 @@ async function uploadRoomImage() {
</div>
<div class="mb-3">
<label class="form-label">Мебель</label>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;">
<div class="room-modal-grid-3col" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;">
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_double_bed" name="furniture" value="double_bed"><label class="form-check-label" for="furn_double_bed">Двуспальная кровать</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_single_beds" name="furniture" value="single_beds"><label class="form-check-label" for="furn_single_beds">Односпальные кровати</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_sofa" name="furniture" value="sofa"><label class="form-check-label" for="furn_sofa">Диван</label></div>
@@ -1798,7 +1833,7 @@ async function uploadRoomImage() {
</div>
<div class="mb-3">
<label class="form-label">Удобства</label>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="room-modal-grid-4col" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_ac" name="amenities" value="has_ac"><label class="form-check-label" for="am_has_ac">🌡️ Кондиционер</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_tv" name="amenities" value="has_tv"><label class="form-check-label" for="am_has_tv">📺 Телевизор</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_fridge" name="amenities" value="has_fridge"><label class="form-check-label" for="am_has_fridge">🧊 Холодильник</label></div>

View File

@@ -49,8 +49,8 @@
.reviews-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: clamp(12px, 3vw, 24px);
margin-bottom: 30px;
}
@@ -145,7 +145,7 @@
.reviews-empty {
text-align: center;
padding: 60px 20px;
padding: clamp(30px, 8vw, 60px) clamp(12px, 3vw, 20px);
color: rgba(255,255,255,0.6);
}
@@ -168,7 +168,7 @@
background: transparent;
border: 2px solid var(--gold);
color: var(--gold);
padding: 12px 30px;
padding: clamp(8px, 2vw, 14px) clamp(20px, 4vw, 32px);
border-radius: 30px;
font-size: 0.95rem;
font-weight: 500;
@@ -182,7 +182,7 @@
}
.reviews-section .btn-gold {
padding: 14px 32px;
padding: clamp(10px, 2vw, 16px) clamp(20px, 4vw, 36px);
font-size: 1rem;
}
@@ -206,14 +206,14 @@
background: #fff;
border-radius: 20px;
width: 100%;
max-width: 560px;
max-width: min(90vw, 560px);
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.review-modal-header {
padding: 24px 28px;
padding: clamp(16px, 3vw, 24px) clamp(18px, 4vw, 28px);
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
@@ -253,7 +253,7 @@
}
.review-modal-body {
padding: 28px;
padding: clamp(16px, 3vw, 28px);
}
.review-form-group {
@@ -328,7 +328,7 @@
gap: 16px;
}
@media (max-width: 500px) {
@media (max-width: 480px) {
.review-row {
grid-template-columns: 1fr;
}
@@ -349,8 +349,8 @@
.review-stars-display {
display: flex;
gap: 4px;
font-size: 1.5rem;
min-width: 150px;
font-size: clamp(1rem, 3vw, 1.5rem);
min-width: min(100px, 40vw);
}
.review-stars-display i {
@@ -450,7 +450,7 @@
}
.review-modal-footer {
padding: 20px 28px;
padding: clamp(12px, 2vw, 20px) clamp(18px, 4vw, 28px);
border-top: 1px solid #eee;
display: flex;
gap: 12px;
@@ -478,7 +478,7 @@
}
.review-btn-submit {
padding: 12px 28px;
padding: clamp(8px, 2vw, 14px) clamp(16px, 4vw, 32px);
background: var(--gold);
border: none;
border-radius: 12px;
@@ -516,7 +516,7 @@
.review-success-message {
text-align: center;
padding: 40px 20px;
padding: clamp(20px, 5vw, 40px) clamp(12px, 3vw, 20px);
}
.review-success-message i {

View File

@@ -89,7 +89,7 @@ h1, h2, h3, h4 {
animation: pulseGlow 1.5s ease-in-out infinite;
}
#preloader .loader-bar {
width: 200px; height: 3px;
width: min(200px, 80vw); height: 3px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
margin-top: 20px;
@@ -337,7 +337,7 @@ h1, h2, h3, h4 {
display: inline-block;
background: rgba(212,168,67,0.15);
color: var(--gold);
padding: 6px 20px;
padding: clamp(4px, 1vw, 8px) clamp(12px, 3vw, 24px);
border-radius: 50px;
font-size: clamp(0.7rem, 1vw, 0.8rem);
letter-spacing: 2px;
@@ -364,7 +364,7 @@ h1, h2, h3, h4 {
/* Decorative elements */
.abkhazian-pattern {
width: 60px;
width: clamp(40px, 8vw, 80px);
height: 4px;
background: linear-gradient(90deg, var(--gold), var(--accent), var(--gold));
margin: 15px auto;
@@ -587,7 +587,7 @@ h1, h2, h3, h4 {
padding-top: clamp(15px, 3vw, 20px);
border-top: 1px solid #eee;
flex-wrap: wrap;
gap: 15px;
gap: clamp(8px, 2vw, 15px);
}
.room-footer > div { text-align: left; }
.room-footer > button { flex-shrink: 0; }
@@ -667,44 +667,7 @@ h1, h2, h3, h4 {
font-weight: 600;
font-size: clamp(0.85rem, 1.5vw, 1rem);
}
.gallery-item {
position: relative;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
}
.gallery-item:nth-child(1) {
grid-column: span 2;
grid-row: span 2;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.gallery-item:hover img {
transform: scale(1.1);
}
.gallery-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(to top, rgba(10,77,104,0.7) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
align-items: flex-end;
padding: 20px;
}
.gallery-item:hover .gallery-overlay {
opacity: 1;
}
.gallery-overlay span {
color: #fff;
font-weight: 600;
font-size: 1rem;
}
/* Activities */
.activity-card {
@@ -1145,7 +1108,7 @@ h1, h2, h3, h4 {
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: #fff;
border: none;
padding: 14px 40px;
padding: clamp(10px, 2.5vw, 16px) clamp(20px, 5vw, 45px);
border-radius: 50px;
font-weight: 700;
font-size: clamp(0.9rem, 1.4vw, 1rem);
@@ -1334,28 +1297,28 @@ h1, h2, h3, h4 {
margin: 20px 0;
}
.ornament-line {
width: 40px;
width: clamp(30px, 5vw, 50px);
height: 2px;
background: var(--gold);
}
.ornament-diamond {
width: 10px;
height: 10px;
width: clamp(8px, 1.5vw, 12px);
height: clamp(8px, 1.5vw, 12px);
background: var(--gold);
transform: rotate(45deg);
}
/* Host portrait styling */
.host-portrait {
width: 150px;
height: 150px;
width: clamp(100px, 25vw, 180px);
height: clamp(100px, 25vw, 180px);
border-radius: 50%;
background: linear-gradient(135deg, var(--gold), var(--accent));
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
font-size: 3.5rem;
font-size: clamp(2rem, 6vw, 3.5rem);
color: var(--dark);
box-shadow: 0 10px 30px rgba(212,168,67,0.3);
position: relative;
@@ -1363,8 +1326,8 @@ h1, h2, h3, h4 {
}
.host-portrait-ring {
position: absolute;
width: 170px;
height: 170px;
width: calc(100% + 20px);
height: calc(100% + 20px);
border-radius: 50%;
border: 3px solid rgba(212,168,67,0.3);
top: 50%;

View File

@@ -141,7 +141,7 @@ app.use('/metrics', (req, res, next) => {
res.set('WWW-Authenticate', 'Basic realm="Monitoring"');
return res.status(401).send('Authentication required');
}
const auth = new Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':');
const auth = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':');
const user = auth[0];
const pass = auth[1];
if (user === MONITORING_USER && pass === MONITORING_PASSWORD) {
@@ -534,28 +534,6 @@ settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.require
reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
app.post('/api/promocodes/validate', (req, res) => {
const { code, room_type, checkin, checkout, guests } = req.body;
if (!code) return res.status(400).json({ error: 'Promocode required' });
db.get(`SELECT * FROM promocodes WHERE code = ? AND is_active = 1`, [code], (err, promo) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!promo) return res.status(404).json({ error: 'Invalid or expired promocode' });
const configModule = require('./config');
const guestsCount = parseInt(guests) || 1;
const basePrice = configModule.calculateBasePrice(room_type, checkin, checkout) * guestsCount;
const discountAmount = Math.round(basePrice * promo.discount_percent / 100);
const totalPrice = basePrice - discountAmount;
res.json({
valid: true,
discount_percent: promo.discount_percent,
base_price: basePrice,
discount_amount: discountAmount,
total_price: totalPrice,
code: promo.code
});
});
});
app.get('/api/translations/:lang', (req, res) => {
const lang = req.params.lang;
const translations = translationsModule.getTranslations(lang);

View File

@@ -1,12 +0,0 @@
◇ injected env (3) from .env // tip: ⌘ multiple files { path: ['.env.local', '.env'] }
✅ HOTEL777KEY is HFwy+tfAljHEq8R21BCRt+Ps4SN65bu8zFagA68N24s
✅ Hotel 777 server running on http://localhost:3000
✅ Superadmin "admin" updated from .env
PATCH /api/admin/bookings/:id hit 2 { status: 'оплачена' }
PATCH /api/admin/bookings/:id hit 2 { status: 'заселена' }
PATCH /api/admin/bookings/:id hit 2 { status: 'оплачена' }
PATCH /api/admin/bookings/:id hit 2 { status: 'новая' }
PATCH /api/admin/bookings/:id hit 2 { status: 'оплачена' }
PATCH /api/admin/bookings/:id hit 2 { status: 'выехала' }
PATCH /api/admin/bookings/:id hit 2 { status: 'оплачена' }
PATCH /api/admin/bookings/:id hit 2 { status: 'новая' }

View File

View File

@@ -1,7 +1,8 @@
const http = require('http');
const BASE_URL = 'http://localhost:3000';
const API_KEY = 'HFwy+tfAljHEq8R21BCRt+Ps4SN65bu8zFagA68N24s';
const API_KEY = process.env.HOTEL777KEY;
if (!API_KEY) { console.error('HOTEL777KEY not set'); process.exit(1); }
function apiRequest(method, path, body = null, headers = {}) {
return new Promise((resolve, reject) => {
@@ -51,8 +52,8 @@ async function runTests() {
try {
console.log('--- Тест 1: Вход в систему админа ---');
const loginResult = await apiRequest('POST', '/api/auth/login', {
login: 'kalugin66',
password: '9630523802Hotell777'
login: process.env.ADMIN_LOGIN || 'admin',
password: process.env.ADMIN_PASSWORD || 'admin'
});
assert(loginResult.status === 200, 'Авторизация успешна');
jwtToken = loginResult.data.token;