фикс2
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 59s

This commit is contained in:
2026-07-26 23:09:26 +05:00
parent edbe54b915
commit 4989816b2b
8 changed files with 230 additions and 325 deletions

View File

@@ -74,7 +74,7 @@ function getBookingsForAdmin(req, res) {
const total = countRow.total; const total = countRow.total;
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
db.all(`SELECT b.*, p.code as promocode_code, r.name as room_name, r.type as room_type db.all(`SELECT b.*, p.code as promocode_code, r.name as room_name
FROM bookings b FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id LEFT JOIN promocodes p ON b.promocode_id = p.id
LEFT JOIN rooms r ON b.room_id = r.id LEFT JOIN rooms r ON b.room_id = r.id
@@ -112,7 +112,7 @@ function updateBookingStatus(req, res) {
db.run(`UPDATE bookings SET status = ? WHERE id = ?`, [status, bookingId], (err) => { db.run(`UPDATE bookings SET status = ? WHERE id = ?`, [status, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'status', oldValue, status); logHistory(bookingId, req.user.id, req.user.login, 'status', oldValue, status);
db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name, r.type as room_type db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name
FROM bookings b FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id LEFT JOIN promocodes p ON b.promocode_id = p.id
LEFT JOIN rooms r ON b.room_id = r.id LEFT JOIN rooms r ON b.room_id = r.id
@@ -155,7 +155,7 @@ function updateBookingRoom(req, res) {
[room.id, room.type, basePrice, discountAmount, totalPrice, bookingId], (err) => { [room.id, room.type, basePrice, discountAmount, totalPrice, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'room', oldValue, newValue); logHistory(bookingId, req.user.id, req.user.login, 'room', oldValue, newValue);
db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name, r.type as room_type db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name
FROM bookings b FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id LEFT JOIN promocodes p ON b.promocode_id = p.id
LEFT JOIN rooms r ON b.room_id = r.id LEFT JOIN rooms r ON b.room_id = r.id
@@ -194,7 +194,7 @@ function updateBookingComment(req, res) {
db.run(`UPDATE bookings SET comment = ? WHERE id = ?`, [comment || null, bookingId], (err) => { db.run(`UPDATE bookings SET comment = ? WHERE id = ?`, [comment || null, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'comment', oldValue, comment || 'Нет'); logHistory(bookingId, req.user.id, req.user.login, 'comment', oldValue, comment || 'Нет');
db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name, r.type as room_type db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name
FROM bookings b FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id LEFT JOIN promocodes p ON b.promocode_id = p.id
LEFT JOIN rooms r ON b.room_id = r.id LEFT JOIN rooms r ON b.room_id = r.id
@@ -223,7 +223,7 @@ function updateBookingDiscount(req, res) {
[discount_percent, discountAmount, totalPrice, bookingId], (err) => { [discount_percent, discountAmount, totalPrice, bookingId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' }); if (err) return res.status(500).json({ error: 'Database error' });
logHistory(bookingId, req.user.id, req.user.login, 'discount_percent', oldValue.toString(), discount_percent.toString()); logHistory(bookingId, req.user.id, req.user.login, 'discount_percent', oldValue.toString(), discount_percent.toString());
db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name, r.type as room_type db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name
FROM bookings b FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id LEFT JOIN promocodes p ON b.promocode_id = p.id
LEFT JOIN rooms r ON b.room_id = r.id LEFT JOIN rooms r ON b.room_id = r.id
@@ -320,7 +320,7 @@ function updateBookingDetails(req, res) {
}); });
}); });
function finishUpdate() { function finishUpdate() {
db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name, r.type as room_type db.get(`SELECT b.*, p.code as promocode_code, r.name as room_name
FROM bookings b FROM bookings b
LEFT JOIN promocodes p ON b.promocode_id = p.id LEFT JOIN promocodes p ON b.promocode_id = p.id
LEFT JOIN rooms r ON b.room_id = r.id LEFT JOIN rooms r ON b.room_id = r.id

View File

@@ -49,7 +49,7 @@ function init(database, jwtSecret) {
function authenticateToken(req, res, next) { function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; const token = (authHeader && authHeader.split(' ')[1]) || (req.cookies && req.cookies.token);
if (!token) return res.status(401).json({ error: 'Unauthorized' }); if (!token) return res.status(401).json({ error: 'Unauthorized' });
jwt.verify(token, JWT_SECRET, (err, user) => { jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token' }); if (err) return res.status(403).json({ error: 'Invalid or expired token' });
@@ -81,6 +81,7 @@ function login(req, res) {
clearRateLimit(clientIp); clearRateLimit(clientIp);
const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' }); const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' });
res.cookie('token', token, { httpOnly: true, sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 });
res.json({ res.json({
token, token,
user: { id: user.id, login: user.login, full_name: user.full_name, email: user.email, role: user.role } user: { id: user.id, login: user.login, full_name: user.full_name, email: user.email, role: user.role }

View File

@@ -1,9 +1,11 @@
{ {
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"geoip-lite": "^2.0.3", "geoip-lite": "^2.0.3",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",

View File

@@ -960,7 +960,7 @@ function showToast(msg, type = 'success') {
const c = document.getElementById('toastContainer'); const c = document.getElementById('toastContainer');
const t = document.createElement('div'); const t = document.createElement('div');
t.className = 'toast ' + type; t.className = 'toast ' + type;
t.innerHTML = '<span class="toast-icon"><i class="fas fa-' + (type === 'success' ? 'check-circle' : 'exclamation-circle') + '"></i></span><span>' + msg + '</span>'; t.innerHTML = '<span class="toast-icon"><i class="fas fa-' + (type === 'success' ? 'check-circle' : 'exclamation-circle') + '"></i></span><span>' + esc(msg) + '</span>';
c.appendChild(t); c.appendChild(t);
setTimeout(() => t.remove(), 3000); setTimeout(() => t.remove(), 3000);
} }
@@ -1092,12 +1092,10 @@ async function loadDashboard() {
try { try {
const reviewData = await api('/api/admin/reviews'); const reviewData = await api('/api/admin/reviews');
document.getElementById('statPendingReviews')?.textContent != null && (document.getElementById('statPendingReviews').textContent = reviewData.stats.pending); document.getElementById('statPendingReviews')?.textContent != null && (document.getElementById('statPendingReviews').textContent = reviewData.stats.pending);
} catch(e) {} } catch(e) { console.error('loadDashboard reviews failed:', e); }
loadRevenueChart('monthly'); loadRevenueChart('monthly');
} }
let currentChartPeriod = 'monthly';
async function loadRevenueChart(period) { async function loadRevenueChart(period) {
currentChartPeriod = period; currentChartPeriod = period;
document.querySelectorAll('#chartWeekBtn, #chartDayBtn, [onclick*="loadRevenueChart"]').forEach(b => { document.querySelectorAll('#chartWeekBtn, #chartDayBtn, [onclick*="loadRevenueChart"]').forEach(b => {
@@ -1706,26 +1704,6 @@ async function deleteReview(id) {
} }
// Settings Tab Functions // Settings Tab Functions
async function loadSettings() {
try {
const data = await api('/api/admin/settings');
const display = document.getElementById('currentCodeDisplay');
if (display) {
display.value = data.review_code || 'Не установлен';
}
} catch(err) {
const display = document.getElementById('currentCodeDisplay');
if (display) display.value = 'Ошибка загрузки';
}
try {
await loadBackupSettings();
await loadBackupsList();
} catch(err) {
console.error('Failed to load backup module:', err);
}
}
document.getElementById('settingsForm').addEventListener('submit', async e => { document.getElementById('settingsForm').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const newCode = document.getElementById('newReviewCode').value.trim(); const newCode = document.getElementById('newReviewCode').value.trim();
@@ -1754,17 +1732,6 @@ function toggleCodeShow() {
} }
// Backup Functions // Backup Functions
async function loadBackupSettings() {
try {
const settings = await api('/api/admin/backup/settings');
document.getElementById('backupAutoEnabled').checked = settings.backup_auto_enabled === 'true';
document.getElementById('backupAutoTime').value = settings.backup_auto_time || '03:00';
document.getElementById('backupRetentionDays').value = settings.backup_retention_days || '30';
} catch(err) {
console.error('Failed to load backup settings:', err);
}
}
async function saveBackupSettings() { async function saveBackupSettings() {
const enabled = document.getElementById('backupAutoEnabled').checked; const enabled = document.getElementById('backupAutoEnabled').checked;
const time = document.getElementById('backupAutoTime').value; const time = document.getElementById('backupAutoTime').value;
@@ -2037,62 +2004,6 @@ function renderRooms(rooms) {
let editingRoomId = null; let editingRoomId = null;
let currentRooms = []; let currentRooms = [];
function showRoomModal(id) {
editingRoomId = id;
const modal = document.getElementById('roomModal');
const form = document.getElementById('roomForm');
const title = document.getElementById('roomModalTitle');
form.reset();
document.getElementById('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';
document.getElementById('roomImagePreview').removeAttribute('data-path');
document.querySelectorAll('#roomForm input[type="checkbox"]').forEach(cb => cb.checked = false);
if (id) {
title.textContent = 'Редактировать номер';
const room = currentRooms.find(r => r.id === id);
if (room) fillRoomForm(room);
} else {
title.textContent = 'Добавить номер';
}
modal.classList.add('show');
document.body.style.overflow = 'hidden';
}
function fillRoomForm(r) {
document.getElementById('roomName').value = r.name || '';
document.getElementById('roomType').value = r.type || '2x-местный';
document.getElementById('roomDescription').value = r.description || '';
document.getElementById('roomPrice').value = r.price_per_night || 0;
document.getElementById('roomArea').value = r.area_sqm || 20;
document.getElementById('roomMaxGuests').value = r.max_guests || 2;
document.getElementById('roomCount').value = r.rooms_count || 1;
document.getElementById('roomFloors').value = (Array.isArray(r.floors) ? r.floors.join(', ') : '');
document.getElementById('roomExtraBeds').value = r.extra_beds || 0;
document.getElementById('roomExtraBedPrice').value = r.extra_bed_price || 0;
document.getElementById('roomIsActive').checked = r.is_active !== 0;
if (r.image_path) {
const src = r.image_path.startsWith('uploads') ? '/' + r.image_path : r.image_path;
document.getElementById('roomImagePreview').src = src;
document.getElementById('roomImagePreview').dataset.path = r.image_path;
}
const furniture = Array.isArray(r.furniture) ? r.furniture : [];
furniture.forEach(f => {
const cb = document.querySelector('#roomForm input[name="furniture"][value="' + f + '"]');
if (cb) cb.checked = true;
});
const amenities = Array.isArray(r.amenities) ? r.amenities : [];
amenities.forEach(a => {
const cb = document.querySelector('#roomForm input[name="amenities"][value="' + a + '"]');
if (cb) cb.checked = true;
});
}
function closeRoomModal() { function closeRoomModal() {
document.getElementById('roomModal').classList.remove('show'); document.getElementById('roomModal').classList.remove('show');
document.body.style.overflow = ''; document.body.style.overflow = '';
@@ -2199,7 +2110,7 @@ async function loadRoomImages(roomId) {
const room = rooms.find(r => r.id === roomId); const room = rooms.find(r => r.id === roomId);
if (!room || !room.images) return; if (!room || !room.images) return;
renderRoomImages(room.images); renderRoomImages(room.images);
} catch(e) {} } catch(e) { console.error('loadRoomImages failed:', e); }
} }
function renderRoomImages(images) { function renderRoomImages(images) {
@@ -2300,36 +2211,43 @@ function renderCalendar(data) {
const days = data.days || {}; const days = data.days || {};
const month = data.month; const month = data.month;
const [y, m] = month.split('-').map(Number); const [y, m] = month.split('-').map(Number);
const firstDay = new Date(y, m-1, 1);
const lastDay = new Date(y, m, 0); const lastDay = new Date(y, m, 0);
const startDayOfWeek = firstDay.getDay() || 7; const dayNames = ['Вс','Пн','Вт','Ср','Чт','Пт','Сб'];
let html = '<table style="width:100%;min-width:700px;border-collapse:collapse;font-size:0.8rem;">'; let html = '<div class="calendar-cards">';
html += '<thead><tr><th style="padding:8px;border:1px solid #e2e8f0;background:#f8fafc;">Месяц</th>';
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
for (let d = 1; d <= lastDay.getDate(); d++) {
html += `<th style="padding:6px 8px;border:1px solid #e2e8f0;background:#f8fafc;text-align:center;font-size:0.7rem;">${d}<br><small>${dayNames[(new Date(y,m-1,d).getDay()||7)-1]}</small></th>`;
}
html += '</tr></thead><tbody>';
rooms.forEach(room => {
html += '<tr><td style="padding:6px 8px;border:1px solid #e2e8f0;font-weight:600;white-space:nowrap;background:#f8fafc;">' + esc(room.type) + '</td>';
for (let d = 1; d <= lastDay.getDate(); d++) { for (let d = 1; d <= lastDay.getDate(); d++) {
const dateStr = `${month}-${String(d).padStart(2,'0')}`; const dateStr = `${month}-${String(d).padStart(2,'0')}`;
const dow = new Date(y, m-1, d).getDay();
const dayName = dayNames[dow];
let hasData = false;
let roomsHtml = '';
rooms.forEach(room => {
const dayData = days[dateStr] ? days[dateStr][room.type] : null; const dayData = days[dateStr] ? days[dateStr][room.type] : null;
const booked = dayData ? dayData.booked : 0; const booked = dayData ? dayData.booked : 0;
const total = dayData ? dayData.total : room.rooms_count; const total = room.rooms_count || 1;
const ratio = total > 0 ? booked / total : 0; const ratio = total > 0 ? booked / total : 0;
let bg = '#dcfce7'; let bg = '#dcfce7';
if (ratio >= 1) bg = '#fee2e2'; if (ratio >= 1) bg = '#fee2e2';
else if (ratio >= 0.7) bg = '#fef3c7'; else if (ratio >= 0.7) bg = '#fef3c7';
const pct = Math.round(ratio * 100);
const title = room.type + ': ' + booked + '/' + total; if (booked > 0) hasData = true;
html += `<td style="padding:4px;border:1px solid #e2e8f0;background:${bg};text-align:center;cursor:pointer;" title="${title}" onclick="showCalendarDay('${dateStr}','${esc(room.type)}')">${booked > 0 ? '<span style="font-weight:600;font-size:0.75rem;">'+booked+'</span>' : ''}</td>`; roomsHtml += `<div class="cal-room-row" style="background:${bg};cursor:pointer;" onclick="showCalendarDay('${dateStr}','${esc(room.type)}')">
} <span class="cal-room-type">${esc(room.type)}</span>
html += '</tr>'; <span class="cal-room-bar"><span class="cal-room-fill" style="width:${pct}%;background:${ratio >= 1 ? '#ef4444' : ratio >= 0.7 ? '#f59e0b' : '#22c55e'}"></span></span>
<span class="cal-room-count">${booked}/${total}</span>
</div>`;
}); });
html += '</tbody></table>';
html += `<div class="cal-card">
<div class="cal-card-header">${d} ${dayName}</div>
<div class="cal-card-rooms">${roomsHtml}</div>
</div>`;
}
html += '</div>';
grid.innerHTML = html; grid.innerHTML = html;
} }
@@ -2602,7 +2520,7 @@ async function loadSecurityKeys() {
<button class="btn btn-sm" style="margin-left:8px;background:#f59e0b;color:#fff;border:none;border-radius:6px;padding:2px 8px;" onclick="regenerateKey('hotel777key')">Перегенерировать</button> <button class="btn btn-sm" style="margin-left:8px;background:#f59e0b;color:#fff;border:none;border-radius:6px;padding:2px 8px;" onclick="regenerateKey('hotel777key')">Перегенерировать</button>
</div> </div>
`; `;
} catch(e) {} } catch(e) { console.error('loadSecurityKeys failed:', e); }
} }
async function regenerateKey(type) { async function regenerateKey(type) {
@@ -2631,7 +2549,7 @@ async function loadEmailSettings() {
document.getElementById('smtpFrom').value = data.smtp_from || ''; document.getElementById('smtpFrom').value = data.smtp_from || '';
document.getElementById('adminEmail').value = data.admin_email || ''; document.getElementById('adminEmail').value = data.admin_email || '';
document.getElementById('emailEnabled').checked = data.email_notifications_enabled === 'true'; document.getElementById('emailEnabled').checked = data.email_notifications_enabled === 'true';
} catch(e) {} } catch(e) { console.error('loadEmailSettings failed:', e); }
} }
async function saveEmailSettings() { async function saveEmailSettings() {
@@ -2672,7 +2590,7 @@ async function loadPhoneSettings() {
document.getElementById('phoneMaxActive').checked = data.maxActive === 'true'; document.getElementById('phoneMaxActive').checked = data.maxActive === 'true';
document.getElementById('phoneInstagramId').value = data.instagramId || ''; document.getElementById('phoneInstagramId').value = data.instagramId || '';
document.getElementById('phoneInstagramActive').checked = data.instagramActive === 'true'; document.getElementById('phoneInstagramActive').checked = data.instagramActive === 'true';
} catch(e) {} } catch(e) { console.error('loadPhoneSettings failed:', e); }
} }
async function savePhoneSettings() { async function savePhoneSettings() {
@@ -2707,7 +2625,7 @@ async function loadTelegramSettings() {
document.getElementById('telegramEnabled').checked = data.telegram_notify_enabled === 'true'; document.getElementById('telegramEnabled').checked = data.telegram_notify_enabled === 'true';
document.getElementById('telegramApiUrl').value = data.telegram_api_url || 'https://api.telegram.org'; document.getElementById('telegramApiUrl').value = data.telegram_api_url || 'https://api.telegram.org';
updateTelegramStatus(data.telegram_proxy_status || 'unknown'); updateTelegramStatus(data.telegram_proxy_status || 'unknown');
} catch(e) {} } catch(e) { console.error('loadTelegramSettings failed:', e); }
} }
function updateTelegramStatus(status) { function updateTelegramStatus(status) {
@@ -2790,7 +2708,7 @@ async function loadSettings() {
try { try {
const data = await api('/api/admin/settings'); const data = await api('/api/admin/settings');
document.getElementById('currentCodeDisplay').value = data.review_code || ''; document.getElementById('currentCodeDisplay').value = data.review_code || '';
} catch(e) {} } catch(e) { console.error('loadSettings review_code failed:', e); }
try { try {
const backupSettings = await api('/api/admin/backup/settings'); const backupSettings = await api('/api/admin/backup/settings');
if (backupSettings.backup_auto_enabled !== undefined) { if (backupSettings.backup_auto_enabled !== undefined) {
@@ -2802,8 +2720,8 @@ async function loadSettings() {
if (backupSettings.backup_retention_days) { if (backupSettings.backup_retention_days) {
document.getElementById('backupRetentionDays').value = backupSettings.backup_retention_days; document.getElementById('backupRetentionDays').value = backupSettings.backup_retention_days;
} }
loadBackups(); loadBackupsList();
} catch(e) {} } catch(e) { console.error('loadSettings backup failed:', e); }
loadSecurityKeys(); loadSecurityKeys();
loadEmailSettings(); loadEmailSettings();
loadPhoneSettings(); loadPhoneSettings();
@@ -3161,11 +3079,11 @@ async function loadIPs(offset) {
'</tr></thead><tbody>'; '</tr></thead><tbody>';
data.entries.forEach(e => { data.entries.forEach(e => {
html += `<tr>` + html += `<tr>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;font-family:monospace;">${e.ip}</td>` + `<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;font-family:monospace;">${esc(e.ip)}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${e.country || '-'} <span style="color:#94a3b8;font-size:0.7rem;">${e.country_code || ''}</span></td>` + `<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${esc(e.country || '-')} <span style="color:#94a3b8;font-size:0.7rem;">${esc(e.country_code || '')}</span></td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${e.accept_language || '-'}</td>` + `<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${esc(e.accept_language || '-')}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;white-space:nowrap;">${e.created_at ? e.created_at.substring(0, 16) : e.visit_date}</td>` + `<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;white-space:nowrap;">${e.created_at ? e.created_at.substring(0, 16) : e.visit_date}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8rem;color:#64748b;" title="${e.page_path}">${e.page_path}</td>` + `<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8rem;color:#64748b;" title="${esc(e.page_path)}">${esc(e.page_path)}</td>` +
`</tr>`; `</tr>`;
}); });
html += '</tbody></table>'; html += '</tbody></table>';
@@ -3185,6 +3103,13 @@ async function loadIPs(offset) {
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#ef4444;padding:20px;">Ошибка загрузки</div>'; document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#ef4444;padding:20px;">Ошибка загрузки</div>';
} }
} }
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var modals = document.querySelectorAll('.modal-backdrop-custom.show');
modals.forEach(function(m) { m.classList.remove('show'); });
if (modals.length > 0) document.body.style.overflow = '';
}
});
</script> </script>
<!-- Room Edit Modal --> <!-- Room Edit Modal -->
@@ -3317,6 +3242,17 @@ async function loadIPs(offset) {
.room-admin-actions { display: flex; gap: 8px; margin-top: 12px; border-top: 1px solid #f1f5f9; padding-top: 12px; } .room-admin-actions { display: flex; gap: 8px; margin-top: 12px; border-top: 1px solid #f1f5f9; padding-top: 12px; }
.room-admin-actions .btn-primary-custom { flex: 1; } .room-admin-actions .btn-primary-custom { flex: 1; }
.room-admin-actions .btn-danger-custom { padding: 8px 12px; } .room-admin-actions .btn-danger-custom { padding: 8px 12px; }
.calendar-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
@media (min-width: 768px) { .calendar-cards { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
.cal-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
.cal-card-header { padding: 8px 12px; background: #f8fafc; font-weight: 600; font-size: 0.85rem; color: #0f172a; border-bottom: 1px solid #e2e8f0; }
.cal-card-rooms { padding: 4px 0; }
.cal-room-row { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 0.75rem; border-bottom: 1px solid #f1f5f9; }
.cal-room-row:last-child { border-bottom: none; }
.cal-room-type { width: 80px; flex-shrink: 0; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cal-room-bar { flex: 1; height: 6px; background: rgba(0,0,0,0.1); border-radius: 3px; overflow: hidden; }
.cal-room-fill { display: block; height: 100%; border-radius: 3px; transition: width 0.3s ease; }
.cal-room-count { width: 36px; flex-shrink: 0; text-align: right; font-weight: 600; font-size: 0.7rem; }
</style> </style>
<div class="modal-backdrop-custom" id="seasonalModal"> <div class="modal-backdrop-custom" id="seasonalModal">

View File

@@ -57,7 +57,6 @@ html, body {
gap: 5px; gap: 5px;
} }
.nav-link { .nav-link {
padding: 10px 15px !important;
border-radius: 8px; border-radius: 8px;
} }
.nav-link:hover { .nav-link:hover {
@@ -115,12 +114,12 @@ h1, h2, h3, h4 {
/* Navigation */ /* Navigation */
.navbar { .navbar {
transition: all 0.4s ease; transition: transform 0.4s ease, box-shadow 0.4s ease, background 0.4s ease, color 0.4s ease, opacity 0.4s ease;
padding: 20px 0; padding: 20px 0;
background: transparent; background: transparent;
} }
.navbar.scrolled { #mainNavbar.navbar.scrolled {
background: rgba(10, 77, 104, 0.95) !important; background: rgba(10, 77, 104, 0.95);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
padding: 10px 0; padding: 10px 0;
box-shadow: 0 4px 30px rgba(0,0,0,0.15); box-shadow: 0 4px 30px rgba(0,0,0,0.15);
@@ -128,17 +127,17 @@ h1, h2, h3, h4 {
.navbar-brand { .navbar-brand {
font-family: 'Playfair Display', serif; font-family: 'Playfair Display', serif;
font-size: 1.8rem; font-size: 1.8rem;
color: #fff !important; color: #fff;
font-weight: 700; font-weight: 700;
} }
.navbar-brand span { color: var(--gold); } .navbar-brand span { color: var(--gold); }
.nav-link { .nav-link {
color: rgba(255,255,255,0.9) !important; color: rgba(255,255,255,0.9);
font-weight: 500; font-weight: 500;
font-size: 0.95rem; font-size: 0.95rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding: 8px 16px !important; padding: 8px 16px;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
position: relative; position: relative;
} }
.nav-link::after { .nav-link::after {
@@ -147,11 +146,11 @@ h1, h2, h3, h4 {
bottom: 0; left: 50%; bottom: 0; left: 50%;
width: 0; height: 2px; width: 0; height: 2px;
background: var(--gold); background: var(--gold);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
transform: translateX(-50%); transform: translateX(-50%);
} }
.nav-link:hover::after { width: 60%; } .nav-link:hover::after { width: 60%; }
.nav-link:hover { color: var(--gold) !important; } #mainNavbar .nav-link:hover { color: var(--gold); }
/* Hero Section */ /* Hero Section */
.hero { .hero {
@@ -266,7 +265,7 @@ h1, h2, h3, h4 {
font-weight: 700; font-weight: 700;
font-size: clamp(0.85rem, 1.2vw, 1rem); font-size: clamp(0.85rem, 1.2vw, 1rem);
letter-spacing: 0.5px; letter-spacing: 0.5px;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -286,7 +285,7 @@ h1, h2, h3, h4 {
border-radius: 50px; border-radius: 50px;
font-weight: 600; font-weight: 600;
font-size: clamp(0.85rem, 1.2vw, 1rem); font-size: clamp(0.85rem, 1.2vw, 1rem);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -298,8 +297,7 @@ h1, h2, h3, h4 {
border-color: #fff; border-color: #fff;
transform: translateY(-3px); transform: translateY(-3px);
} }
.btn-whatsapp { .btn-social {
background: #25D366;
color: #fff; color: #fff;
border: none; border: none;
width: 44px; width: 44px;
@@ -307,89 +305,27 @@ h1, h2, h3, h4 {
border-radius: 50%; border-radius: 50%;
font-weight: 700; font-weight: 700;
font-size: 0.85rem; font-size: 0.85rem;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
text-decoration: none; text-decoration: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
padding: 0; padding: 0;
margin: 0 4px;
} }
.btn-whatsapp:hover { .btn-social:hover {
background: #1ebe57;
transform: translateY(-3px); transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(37,211,102,0.4);
color: #fff;
}
.btn-telegram {
background: #0088cc;
color: #fff;
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
font-weight: 700;
font-size: 0.85rem;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
}
.btn-telegram:hover {
background: #0077b5;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,136,204,0.4);
color: #fff;
}
.btn-max {
background: #471AFF;
color: #fff;
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
font-weight: 700;
font-size: 0.85rem;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
}
.btn-max:hover {
background: #3b15d9;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(71,26,255,0.4);
color: #fff;
}
.btn-instagram {
background: #E4405F;
color: #fff;
border: none;
width: 44px;
height: 44px;
border-radius: 50%;
font-weight: 700;
font-size: 0.85rem;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0;
}
.btn-instagram:hover {
background: #d12e4e;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(228,64,95,0.4);
color: #fff; color: #fff;
} }
.btn-social--wa { background: #25D366; }
.btn-social--wa:hover { background: #1ebe57; box-shadow: 0 8px 20px rgba(37,211,102,0.4); }
.btn-social--tg { background: #0088cc; }
.btn-social--tg:hover { background: #0077b5; box-shadow: 0 8px 20px rgba(0,136,204,0.4); }
.btn-social--max { background: #471AFF; }
.btn-social--max:hover { background: #3b15d9; box-shadow: 0 8px 20px rgba(71,26,255,0.4); }
.btn-social--ig { background: #E4405F; }
.btn-social--ig:hover { background: #d12e4e; box-shadow: 0 8px 20px rgba(228,64,95,0.4); }
.max-icon-circle { .max-icon-circle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -417,7 +353,7 @@ h1, h2, h3, h4 {
font-size: 0.7em; font-size: 0.7em;
line-height: 1; line-height: 1;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.footer-social a:hover .max-icon-circle-footer { .footer-social a:hover .max-icon-circle-footer {
background: #471AFF; background: #471AFF;
@@ -582,7 +518,7 @@ h1, h2, h3, h4 {
gap: clamp(10px, 2vw, 15px); gap: clamp(10px, 2vw, 15px);
padding: clamp(10px, 2vw, 15px); padding: clamp(10px, 2vw, 15px);
border-radius: 12px; border-radius: 12px;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.feature-item:hover { .feature-item:hover {
background: rgba(212,168,67,0.08); background: rgba(212,168,67,0.08);
@@ -617,7 +553,7 @@ h1, h2, h3, h4 {
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.08); box-shadow: 0 10px 40px rgba(0,0,0,0.08);
transition: all 0.4s ease; transition: transform 0.4s ease, box-shadow 0.4s ease, background 0.4s ease, color 0.4s ease, opacity 0.4s ease;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -773,7 +709,7 @@ h1, h2, h3, h4 {
border-radius: 50px; border-radius: 50px;
font-weight: 600; font-weight: 600;
font-size: clamp(0.8rem, 1.2vw, 0.9rem); font-size: clamp(0.8rem, 1.2vw, 0.9rem);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
cursor: pointer; cursor: pointer;
} }
.btn-book:hover { .btn-book:hover {
@@ -898,7 +834,7 @@ h1, h2, h3, h4 {
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
z-index: 2; z-index: 2;
line-height: 1; line-height: 1;
} }
@@ -939,7 +875,7 @@ h1, h2, h3, h4 {
overflow: hidden; overflow: hidden;
height: clamp(250px, 40vw, 350px); height: clamp(250px, 40vw, 350px);
cursor: pointer; cursor: pointer;
transition: all 0.4s ease; transition: transform 0.4s ease, box-shadow 0.4s ease, background 0.4s ease, color 0.4s ease, opacity 0.4s ease;
} }
.activity-card:hover { .activity-card:hover {
transform: translateY(-8px); transform: translateY(-8px);
@@ -994,7 +930,7 @@ h1, h2, h3, h4 {
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px; border-radius: 20px;
padding: clamp(20px, 4vw, 35px); padding: clamp(20px, 4vw, 35px);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
height: 100%; height: 100%;
} }
.review-card:hover { .review-card:hover {
@@ -1101,7 +1037,7 @@ h1, h2, h3, h4 {
font-weight: 700; font-weight: 700;
font-size: clamp(0.9rem, 1.5vw, 1.1rem); font-size: clamp(0.9rem, 1.5vw, 1.1rem);
text-decoration: none; text-decoration: none;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.host-contact:hover { .host-contact:hover {
background: var(--primary-light); background: var(--primary-light);
@@ -1214,7 +1150,7 @@ h1, h2, h3, h4 {
color: rgba(255,255,255,0.6); color: rgba(255,255,255,0.6);
text-decoration: none; text-decoration: none;
font-size: clamp(0.8rem, 1.2vw, 0.9rem); font-size: clamp(0.8rem, 1.2vw, 0.9rem);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.footer-links a:hover { .footer-links a:hover {
color: var(--gold); color: var(--gold);
@@ -1235,7 +1171,7 @@ h1, h2, h3, h4 {
justify-content: center; justify-content: center;
color: rgba(255,255,255,0.6); color: rgba(255,255,255,0.6);
text-decoration: none; text-decoration: none;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.footer-social a:hover { .footer-social a:hover {
background: var(--gold); background: var(--gold);
@@ -1319,7 +1255,7 @@ h1, h2, h3, h4 {
font-weight: 700; font-weight: 700;
font-size: clamp(0.8rem, 1vw, 0.9rem); font-size: clamp(0.8rem, 1vw, 0.9rem);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
white-space: nowrap; white-space: nowrap;
} }
.cookie-accept-btn:hover { .cookie-accept-btn:hover {
@@ -1335,7 +1271,7 @@ h1, h2, h3, h4 {
.animate-on-scroll { .animate-on-scroll {
opacity: 0; opacity: 0;
transform: translateY(40px); transform: translateY(40px);
transition: all 0.8s ease; transition: opacity 0.8s ease, transform 0.8s ease;
} }
.animate-on-scroll.visible { .animate-on-scroll.visible {
opacity: 1; opacity: 1;
@@ -1395,7 +1331,7 @@ h1, h2, h3, h4 {
border-radius: 50px; border-radius: 50px;
font-weight: 700; font-weight: 700;
font-size: clamp(0.9rem, 1.4vw, 1rem); font-size: clamp(0.9rem, 1.4vw, 1rem);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
@@ -1431,7 +1367,7 @@ h1, h2, h3, h4 {
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
padding: 12px 18px; padding: 12px 18px;
font-size: clamp(0.85rem, 1.3vw, 0.95rem); font-size: clamp(0.85rem, 1.3vw, 0.95rem);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.form-control:focus, .form-select:focus { .form-control:focus, .form-select:focus {
border-color: var(--primary-light); border-color: var(--primary-light);
@@ -1452,7 +1388,7 @@ h1, h2, h3, h4 {
font-weight: 700; font-weight: 700;
font-size: clamp(0.9rem, 1.4vw, 1rem); font-size: clamp(0.9rem, 1.4vw, 1rem);
width: 100%; width: 100%;
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
} }
.btn-submit-booking:hover { .btn-submit-booking:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -1475,7 +1411,7 @@ h1, h2, h3, h4 {
font-size: clamp(1.2rem, 3vw, 1.5rem); font-size: clamp(1.2rem, 3vw, 1.5rem);
z-index: 1000; z-index: 1000;
box-shadow: 0 5px 25px rgba(37,211,102,0.4); box-shadow: 0 5px 25px rgba(37,211,102,0.4);
transition: all 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
text-decoration: none; text-decoration: none;
animation: phonePulse 2s ease infinite; animation: phonePulse 2s ease infinite;
} }
@@ -1602,7 +1538,7 @@ h1, h2, h3, h4 {
.reveal-img { .reveal-img {
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
transition: all 0.6s ease; transition: transform 0.6s ease, box-shadow 0.6s ease, background 0.6s ease, opacity 0.6s ease;
} }
.reveal-img.visible { .reveal-img.visible {
opacity: 1; opacity: 1;
@@ -1688,7 +1624,7 @@ h1, h2, h3, h4 {
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px; border-radius: 20px;
padding: clamp(18px, 3.5vw, 28px); padding: clamp(18px, 3.5vw, 28px);
transition: all 0.35s ease; transition: transform 0.35s ease, box-shadow 0.35s ease, background 0.35s ease, color 0.35s ease, opacity 0.35s ease;
height: 100%; height: 100%;
} }
.tips-card:hover { .tips-card:hover {
@@ -1799,7 +1735,7 @@ h1, h2, h3, h4 {
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; border-radius: 16px;
padding: clamp(14px, 2.5vw, 20px); padding: clamp(14px, 2.5vw, 20px);
transition: all 0.35s ease; transition: transform 0.35s ease, box-shadow 0.35s ease, background 0.35s ease, color 0.35s ease, opacity 0.35s ease;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }

View File

@@ -20,10 +20,11 @@
</div> </div>
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top"> <nav class="navbar navbar-expand-lg fixed-top" id="mainNavbar">
<div class="container"> <div class="container">
<a class="navbar-brand" href="#">HOTEL <span>777</span></a> <a class="navbar-brand" href="#">HOTEL <span>777</span></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMenu"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMenu"
aria-label="Меню" aria-expanded="false" aria-controls="navMenu">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navMenu"> <div class="collapse navbar-collapse" id="navMenu">
@@ -61,10 +62,10 @@
<div class="hero-buttons"> <div class="hero-buttons">
<a href="#rooms" class="btn-gold"><i class="fas fa-bed"></i> <span data-i18n="hero.btn_rooms">Выбрать номер</span></a> <a href="#rooms" class="btn-gold"><i class="fas fa-bed"></i> <span data-i18n="hero.btn_rooms">Выбрать номер</span></a>
<a href="tel:89409270080" class="btn-outline-white" data-phone="tel"><i class="fas fa-phone"></i> <span data-i18n="hero.btn_call">Позвонить</span></a> <a href="tel:89409270080" class="btn-outline-white" data-phone="tel"><i class="fas fa-phone"></i> <span data-i18n="hero.btn_call">Позвонить</span></a>
<a href="https://wa.me/89409270080" class="btn-whatsapp" target="_blank" title="Написать в WhatsApp" data-phone="wa"><i class="fab fa-whatsapp"></i></a> <a href="https://wa.me/89409270080" class="btn-social btn-social--wa" target="_blank" title="Написать в WhatsApp" data-phone="wa"><i class="fab fa-whatsapp"></i></a>
<a href="https://t.me/+89409270080" class="btn-telegram" target="_blank" title="Написать в Telegram" data-phone="tg"><i class="fab fa-telegram"></i></a> <a href="https://t.me/+89409270080" class="btn-social btn-social--tg" target="_blank" title="Написать в Telegram" data-phone="tg"><i class="fab fa-telegram"></i></a>
<a href="https://max.ru/kalugin66" class="btn-max" target="_blank" title="Написать в Max" data-phone="max"><span class="max-icon-circle">M</span></a> <a href="https://max.ru/kalugin66" class="btn-social btn-social--max" target="_blank" title="Написать в Max" data-phone="max" aria-label="Max messenger"><span class="max-icon-circle">M</span></a>
<a href="#" data-phone="ig" class="btn-instagram" target="_blank" title="Instagram"><i class="fab fa-instagram"></i></a> <a href="#" data-phone="ig" class="btn-social btn-social--ig" target="_blank" title="Instagram"><i class="fab fa-instagram"></i></a>
</div> </div>
</div> </div>
</div> </div>
@@ -723,7 +724,7 @@
</div> </div>
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p data-i18n="footer.copyright">© 2025 Hotel 777. Хозяин — Рауф Алексеевич. Все права защищены. Абхазия, Гудаутский район, с. Мгудзырхуа.</p> <p data-i18n="footer.copyright">© <span id="copyrightYear">2025</span> Hotel 777. Хозяин — Рауф Алексеевич. Все права защищены. Абхазия, Гудаутский район, с. Мгудзырхуа.</p>
<p class="footer-legal"> <p class="footer-legal">
<a href="/privacy">Политика конфиденциальности</a> <a href="/privacy">Политика конфиденциальности</a>
<span class="footer-legal-divider">|</span> <span class="footer-legal-divider">|</span>
@@ -740,7 +741,7 @@
<!-- Booking Modal --> <!-- Booking Modal -->
<div class="modal fade" id="bookingModal" tabindex="-1"> <div class="modal fade" id="bookingModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" style="font-family: 'Playfair Display', serif; font-size: 1.5rem;"> <h5 class="modal-title" style="font-family: 'Playfair Display', serif; font-size: 1.5rem;">
@@ -751,33 +752,36 @@
<div class="modal-body"> <div class="modal-body">
<form id="bookingForm"> <form id="bookingForm">
<div class="mb-3"> <div class="mb-3">
<label class="form-label"><span data-i18n="booking.selected_room">Выбранный номер</span></label> <label class="form-label"><i class="fas fa-door-open me-1"></i> <span data-i18n="booking.selected_room">Выбранный номер</span></label>
<input type="text" class="form-control" id="selectedRoom" name="room" readonly style="background: #f0f4f8; font-weight: 600;"> <input type="text" class="form-control" id="selectedRoom" name="room" readonly style="background: #f0f4f8; font-weight: 600;">
<input type="hidden" id="selectedRoomId" name="room_id"> <input type="hidden" id="selectedRoomId" name="room_id">
</div> </div>
<div class="row g-3"> <div class="row g-2">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><span data-i18n="booking.your_name">Ваше имя</span></label> <label class="form-label"><i class="fas fa-user me-1"></i> <span data-i18n="booking.your_name">Ваше имя</span></label>
<input type="text" class="form-control" name="name" data-i18n-placeholder="booking.name_placeholder" placeholder="Иван Иванов" required> <input type="text" class="form-control" name="name" data-i18n-placeholder="booking.name_placeholder" placeholder="Иван Иванов" required>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label"><span data-i18n="booking.phone">Телефон</span></label> <label class="form-label"><i class="fas fa-phone me-1"></i> <span data-i18n="booking.phone">Телефон</span></label>
<input type="tel" class="form-control" name="phone" data-i18n-placeholder="booking.phone_placeholder" placeholder="+7 (___) ___-__-__" required> <input type="tel" class="form-control" name="phone" data-i18n-placeholder="booking.phone_placeholder" placeholder="+7 (___) ___-__-__" required>
</div> </div>
</div> </div>
<div class="row g-3 mt-1"> <div class="row g-2 mt-2">
<div class="col-md-6"> <div class="col-5">
<label class="form-label"><span data-i18n="booking.checkin">Дата заезда</span></label> <label class="form-label"><i class="fas fa-calendar-alt me-1"></i> <span data-i18n="booking.checkin">Дата заезда</span></label>
<input type="date" class="form-control" name="checkin" required> <input type="date" class="form-control" name="checkin" required>
</div> </div>
<div class="col-md-6"> <div class="col-2 d-flex align-items-center justify-content-center" style="padding-top: 1.8rem;">
<label class="form-label"><span data-i18n="booking.checkout">Дата выезда</span></label> <span id="nightsCount" style="background:#f0f4f8;padding:4px 12px;border-radius:20px;font-size:0.85rem;color:#64748b;white-space:nowrap;">1 ночь</span>
</div>
<div class="col-5">
<label class="form-label"><i class="fas fa-calendar-check me-1"></i> <span data-i18n="booking.checkout">Дата выезда</span></label>
<input type="date" class="form-control" name="checkout" required> <input type="date" class="form-control" name="checkout" required>
</div> </div>
</div> </div>
<div class="row g-3 mt-1"> <div class="row g-2 mt-2">
<div class="col-md-6"> <div class="col-md-5">
<label class="form-label"><span data-i18n="booking.guests">Кол-во гостей</span></label> <label class="form-label"><i class="fas fa-users me-1"></i> <span data-i18n="booking.guests">Кол-во гостей</span></label>
<select class="form-select" name="guests"> <select class="form-select" name="guests">
<option value="1" data-i18n="booking.guest_1">1 гость</option> <option value="1" data-i18n="booking.guest_1">1 гость</option>
<option value="2" data-i18n="booking.guest_2">2 гостя</option> <option value="2" data-i18n="booking.guest_2">2 гостя</option>
@@ -785,21 +789,19 @@
<option value="4" data-i18n="booking.guest_4">4 гостя</option> <option value="4" data-i18n="booking.guest_4">4 гостя</option>
</select> </select>
</div> </div>
<div class="col-md-6"> <div class="col-md-7">
<label class="form-label"><span data-i18n="booking.wishes">Пожелания</span></label> <label class="form-label"><i class="fas fa-comment me-1"></i> <span data-i18n="booking.wishes">Пожелания</span></label>
<input type="text" class="form-control" name="wishes" data-i18n-placeholder="booking.wishes_placeholder" placeholder="Например: вид на море"> <input type="text" class="form-control" name="wishes" data-i18n-placeholder="booking.wishes_placeholder" placeholder="Вид на море, этаж...">
</div> </div>
</div> </div>
<div class="row g-3 mt-1"> <div class="mt-2">
<div class="col-md-6"> <label class="form-label"><i class="fas fa-tag me-1"></i> <span data-i18n="booking.promocode">Промокод</span></label>
<label class="form-label"><span data-i18n="booking.promocode">Промокод</span></label> <div class="input-group">
<input type="text" class="form-control" id="promocodeInput" name="promocode" data-i18n-placeholder="booking.promocode_placeholder" placeholder="Введите промокод" style="text-transform: uppercase;"> <input type="text" class="form-control" id="promocodeInput" name="promocode" data-i18n-placeholder="booking.promocode_placeholder" placeholder="Введите промокод" style="text-transform: uppercase;">
<button type="button" class="btn btn-outline-primary" id="checkPromocodeBtn"><i class="fas fa-check me-1"></i><span data-i18n="booking.promocode_check">Проверить</span></button>
</div>
<div id="promocodeError" class="text-danger small mt-1" style="display: none;"></div> <div id="promocodeError" class="text-danger small mt-1" style="display: none;"></div>
</div> </div>
<div class="col-md-6 d-flex align-items-end">
<button type="button" class="btn btn-outline-primary btn-sm" id="checkPromocodeBtn" style="width: 100%;"><span data-i18n="booking.promocode_check">Проверить промокод</span></button>
</div>
</div>
<div id="priceInfo" class="mt-3" style="display: none; padding: 15px; background: #f8f9fa; border-radius: 10px;"> <div id="priceInfo" class="mt-3" style="display: none; padding: 15px; background: #f8f9fa; border-radius: 10px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span data-i18n="booking.base_price">Базовая стоимость:</span> <span data-i18n="booking.base_price">Базовая стоимость:</span>
@@ -885,6 +887,7 @@
if (instagramId) href = 'https://instagram.com/' + instagramId; if (instagramId) href = 'https://instagram.com/' + instagramId;
active = instagramActive === 'true'; active = instagramActive === 'true';
} }
if (!/^(https?:\/\/|tel:|mailto:|#)/.test(href)) href = '#';
el.setAttribute('href', href); el.setAttribute('href', href);
if (href === '#') active = false; if (href === '#') active = false;
if (!active) el.style.display = 'none'; if (!active) el.style.display = 'none';
@@ -904,7 +907,7 @@
} }
}); });
}) })
.catch(function() {}); .catch(function(err) { console.error('Config load failed:', err); });
function acceptCookies() { function acceptCookies() {
try { localStorage.setItem('cookie_consent', '1'); } catch(e) {} try { localStorage.setItem('cookie_consent', '1'); } catch(e) {}

View File

@@ -84,6 +84,27 @@ const checkoutInput = document.querySelector('[name="checkout"]');
if (checkinInput) checkinInput.min = today; if (checkinInput) checkinInput.min = today;
if (checkoutInput) checkoutInput.min = today; if (checkoutInput) checkoutInput.min = today;
checkinInput.addEventListener('change', function() {
if (this.value) {
var next = new Date(this.value);
next.setDate(next.getDate() + 1);
checkoutInput.min = next.toISOString().split('T')[0];
if (checkoutInput.value && checkoutInput.value <= this.value) {
checkoutInput.value = '';
}
}
updateNightsCount();
});
function updateNightsCount() {
var ci = checkinInput.value, co = checkoutInput.value;
var el = document.getElementById('nightsCount');
if (ci && co && el) {
var n = Math.ceil((new Date(co) - new Date(ci)) / 86400000);
if (n > 0) el.textContent = n + ' ' + (n === 1 ? 'ночь' : n < 5 ? 'ночи' : 'ночей');
}
}
// Booking modal - set room name // Booking modal - set room name
document.querySelectorAll('.btn-book').forEach(btn => { document.querySelectorAll('.btn-book').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
@@ -200,8 +221,8 @@ function validateBookingDates() {
return checkinInput.checkValidity() && checkoutInput.checkValidity(); return checkinInput.checkValidity() && checkoutInput.checkValidity();
} }
document.querySelector('[name="checkin"]').addEventListener('change', function() { validateBookingDates(); if (typeof checkRoomAvailability === 'function') checkRoomAvailability(); }); document.querySelector('[name="checkin"]').addEventListener('change', function() { validateBookingDates(); if (typeof checkRoomAvailability === 'function') checkRoomAvailability(); updateNightsCount(); });
document.querySelector('[name="checkout"]').addEventListener('change', function() { validateBookingDates(); if (typeof checkRoomAvailability === 'function') checkRoomAvailability(); }); document.querySelector('[name="checkout"]').addEventListener('change', function() { validateBookingDates(); if (typeof checkRoomAvailability === 'function') checkRoomAvailability(); updateNightsCount(); });
function updatePriceDisplay(basePrice, discountPercent, discountAmount, totalPrice) { function updatePriceDisplay(basePrice, discountPercent, discountAmount, totalPrice) {
document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽'; document.getElementById('basePriceDisplay').textContent = basePrice + ' ₽';
@@ -532,6 +553,14 @@ window.addEventListener('scroll', () => {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initHeroSlideshow(); initHeroSlideshow();
var cy = document.getElementById('copyrightYear');
if (cy) cy.textContent = new Date().getFullYear();
var navToggler = document.querySelector('.navbar-toggler');
var navMenu = document.getElementById('navMenu');
if (navToggler && navMenu) {
navMenu.addEventListener('show.bs.collapse', () => navToggler.setAttribute('aria-expanded', 'true'));
navMenu.addEventListener('hide.bs.collapse', () => navToggler.setAttribute('aria-expanded', 'false'));
}
}); });
// Gallery Lightbox // Gallery Lightbox
@@ -540,6 +569,7 @@ function openLightbox(src, caption) {
const img = document.getElementById('galleryLightboxImg'); const img = document.getElementById('galleryLightboxImg');
const cap = document.getElementById('galleryLightboxCaption'); const cap = document.getElementById('galleryLightboxCaption');
img.src = src; img.src = src;
img.alt = caption || 'Фото отеля Hotel 777';
cap.textContent = caption || ''; cap.textContent = caption || '';
lb.classList.add('active'); lb.classList.add('active');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';

View File

@@ -7,6 +7,8 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const client = require('prom-client'); const client = require('prom-client');
const multer = require('multer'); const multer = require('multer');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
require('dotenv').config(); require('dotenv').config();
const config = require('./config'); const config = require('./config');
@@ -168,9 +170,9 @@ const roomAvailability = new client.Gauge({
registers: [register] registers: [register]
}); });
const loginAttempts = new client.Gauge({ const loginAttempts = new client.Counter({
name: 'login_attempts', name: 'login_attempts_total',
help: 'Number of login attempts', help: 'Total number of login attempts',
registers: [register] registers: [register]
}); });
@@ -180,6 +182,11 @@ if (!API_KEY) {
} }
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false
}));
app.use(cookieParser());
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
app.use((req, res, next) => { app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', CORS_ORIGIN); res.header('Access-Control-Allow-Origin', CORS_ORIGIN);
@@ -309,7 +316,9 @@ setTimeout(updateMetrics, 5000);
const dataDir = path.join(__dirname, 'data'); const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir); if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
const dbPath = path.join(dataDir, 'bookings.db'); const dbPath = path.join(dataDir, 'bookings.db');
const db = new sqlite3.Database(dbPath); const db = new sqlite3.Database(dbPath, (err) => {
if (err) { console.error('FATAL: Cannot open database:', err.message); process.exit(1); }
});
initDatabase(db); initDatabase(db);
@@ -377,28 +386,28 @@ function initDefaultRooms() {
if (err) return console.error('Check rooms count error:', err); if (err) return console.error('Check rooms count error:', err);
if (row.count > 0) return; if (row.count > 0) return;
db.serialize(() => {
db.run("BEGIN TRANSACTION");
const stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`); const stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`);
config.DEFAULT_ROOMS.forEach(r => { config.DEFAULT_ROOMS.forEach(r => {
stmt.run( stmt.run(
r.type, r.type, r.name, r.description,
r.name, r.rooms_count, r.area_sqm, r.max_guests,
r.description,
r.rooms_count,
r.area_sqm,
r.max_guests,
JSON.stringify(r.furniture || []), JSON.stringify(r.furniture || []),
JSON.stringify(r.amenities || []), JSON.stringify(r.amenities || []),
JSON.stringify(r.floors || []), JSON.stringify(r.floors || []),
r.price_per_night, r.price_per_night, r.image_path,
r.image_path, r.extra_beds || 0, r.extra_bed_price || 0
r.extra_beds || 0,
r.extra_bed_price || 0
); );
}); });
stmt.finalize(); stmt.finalize();
console.log('✅ Default rooms initialized'); db.run("COMMIT", (err) => {
if (err) { console.error('Default rooms commit error:', err); db.run("ROLLBACK"); }
else console.log('✅ Default rooms initialized');
});
});
}); });
} }
@@ -407,7 +416,7 @@ function syncAdmin() {
console.warn('WARNING: ADMIN_LOGIN or ADMIN_PASSWORD not set, skipping admin sync'); console.warn('WARNING: ADMIN_LOGIN or ADMIN_PASSWORD not set, skipping admin sync');
return; return;
} }
const hash = bcrypt.hashSync(ADMIN_PASSWORD, 10); bcrypt.hash(ADMIN_PASSWORD, 10).then(hash => {
db.get(`SELECT id, role FROM users WHERE login = ?`, [ADMIN_LOGIN], (err, row) => { db.get(`SELECT id, role FROM users WHERE login = ?`, [ADMIN_LOGIN], (err, row) => {
if (err) { console.error('Admin sync error:', err); return; } if (err) { console.error('Admin sync error:', err); return; }
if (row) { if (row) {
@@ -423,6 +432,7 @@ function syncAdmin() {
}); });
} }
}); });
}).catch(err => console.error('Admin hash error:', err));
} }
setTimeout(() => { setTimeout(() => {
@@ -503,10 +513,6 @@ app.get('/api/countries', (req, res) => {
}); });
}); });
function safeJsonString(str) {
return str.replace(/'/g, "'").replace(/\\"/g, '"');
}
app.get('/api/cities/:countryCode', (req, res) => { app.get('/api/cities/:countryCode', (req, res) => {
const countryCode = req.params.countryCode; const countryCode = req.params.countryCode;
@@ -520,20 +526,11 @@ app.get('/api/cities/:countryCode', (req, res) => {
try { try {
const data = fs.readFileSync(filePath, 'utf8'); const data = fs.readFileSync(filePath, 'utf8');
let cities = []; let cities = [];
const arrayMatch = data.match(/const\s+CITIES_\w+\s*=\s*(\[.*?\]);/s);
if (arrayMatch) {
try { try {
cities = JSON.parse(safeJsonString(arrayMatch[1])); cities = new Function(data.replace(/^const\s+\w+\s*=\s*/, 'return '))();
} catch (e) { } catch (e) {
const jsMatch = arrayMatch[1].replace(/'/g, '"'); console.error('Failed to parse cities:', e);
try {
cities = JSON.parse(jsMatch);
} catch (e2) {
console.error('Failed to parse cities:', e2);
}
}
} }
res.json({ cities, popular: [], countryCode }); res.json({ cities, popular: [], countryCode });