номер

This commit is contained in:
2026-05-11 16:31:22 +05:00
parent fc63252327
commit 89e54538de
10 changed files with 923 additions and 159 deletions

View File

@@ -5,6 +5,29 @@ const ROOM_TYPES = {
vip: { id: 'Люкс', name: 'Люкс', pricePerGuest: 4500 }
};
const FURNITURE_TYPES = [
{ id: 'double_bed', name: 'Двуспальная кровать' },
{ id: 'single_beds', name: 'Односпальные кровати' },
{ id: 'sofa', name: 'Диван' },
{ id: 'wardrobe', name: 'Шкаф' },
{ id: 'table', name: 'Стол' },
{ id: 'chairs', name: 'Стулья' },
{ id: 'nightstands', name: 'Прикроватные тумбочки' },
{ id: 'hanger', name: 'Вешалка' },
{ id: 'mirror', name: 'Зеркало' }
];
const AMENITY_TYPES = [
{ id: 'has_ac', name: 'Кондиционер', icon: 'snowflake' },
{ id: 'has_tv', name: 'Телевизор', icon: 'tv' },
{ id: 'has_fridge', name: 'Холодильник', icon: 'sink' },
{ id: 'has_wifi', name: 'Wi-Fi интернет', icon: 'wifi' },
{ id: 'has_kettle', name: 'Электрический чайник', icon: 'mug-hot' },
{ id: 'has_hairdryer', name: 'Фен', icon: 'wind' },
{ id: 'has_shower', name: 'Душ в номере', icon: 'shower' },
{ id: 'has_sea_view', name: 'Вид на море', icon: 'water' }
];
const BOOKING_STATUSES = {
NEW: 'новая',
PAID: 'оплачена',
@@ -15,10 +38,66 @@ const BOOKING_STATUSES = {
};
const DEFAULT_ROOMS = [
{ type: ROOM_TYPES.economy.id, name: '2x-местный 1', description: 'Номер на двоих', rooms_count: 15, single_beds: 0, double_beds: 1, has_sofa: 0, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 2, price_per_guest: ROOM_TYPES.economy.pricePerGuest },
{ type: ROOM_TYPES.standard.id, name: '3х-местный 1', description: 'Номер на троих', rooms_count: 8, single_beds: 0, double_beds: 1, has_sofa: 0, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 3, price_per_guest: ROOM_TYPES.standard.pricePerGuest },
{ type: ROOM_TYPES.family.id, name: 'Семейный 1', description: 'Семейный номер', rooms_count: 4, single_beds: 0, double_beds: 2, has_sofa: 0, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 4, price_per_guest: ROOM_TYPES.family.pricePerGuest },
{ type: ROOM_TYPES.vip.id, name: 'Люкс 1', description: 'Люкс', rooms_count: 3, single_beds: 0, double_beds: 1, has_sofa: 1, has_ac: 1, has_wifi: 1, has_shower: 1, max_guests: 4, price_per_guest: ROOM_TYPES.vip.pricePerGuest }
{
type: ROOM_TYPES.economy.id,
name: '2x-местный',
description: 'Уютный номер на двоих с одной или двумя кроватями. Идеально для пары или друзей. Кондиционер, TV, холодильник, Wi-Fi.',
rooms_count: 15,
area_sqm: 20,
furniture: ['double_bed', 'wardrobe', 'chairs', 'nightstands', 'hanger', 'mirror'],
amenities: ['has_ac', 'has_tv', 'has_fridge', 'has_wifi', 'has_kettle'],
floors: [1, 2, 3],
max_guests: 2,
price_per_night: 1500,
image_path: 'img/1faae356b-9f79-489d-8165-c37a47b82040.webp',
extra_beds: 15,
extra_bed_price: 1000
},
{
type: ROOM_TYPES.standard.id,
name: '3х-местный',
description: 'Комфортный трёхместный номер с односпальными или двуспальной кроватью. Просторный и светлый с видом на море.',
rooms_count: 8,
area_sqm: 20,
furniture: ['single_beds', 'double_bed', 'table', 'wardrobe', 'chairs', 'nightstands', 'hanger', 'mirror'],
amenities: ['has_ac', 'has_tv', 'has_fridge', 'has_wifi', 'has_kettle', 'has_sea_view'],
floors: [1, 2, 3],
max_guests: 3,
price_per_night: 2000,
image_path: 'img/1006bc9be-64c6-43e3-9625-1a862d04930c.webp',
extra_beds: 0,
extra_bed_price: 0
},
{
type: ROOM_TYPES.family.id,
name: 'Семейный',
description: 'Просторный двухкомнатный номер для семьи до 4 человек. Две спальни с двуспальными кроватями. Возможны дополнительные места.',
rooms_count: 4,
area_sqm: 40,
furniture: ['double_bed', 'wardrobe', 'chairs', 'nightstands', 'hanger', 'mirror'],
amenities: ['has_ac', 'has_tv', 'has_fridge', 'has_wifi', 'has_kettle', 'has_hairdryer'],
floors: [2],
max_guests: 4,
price_per_night: 3000,
image_path: 'img/1eae46658-cfca-4b65-82f0-e5868af5541b.webp',
extra_beds: 15,
extra_bed_price: 1000
},
{
type: ROOM_TYPES.vip.id,
name: 'Люкс',
description: 'Элегантный двухкомнатный люкс с двуспальной кроватью, гостиной зоной и великолепным видом на море. Премиум-комфорт.',
rooms_count: 3,
area_sqm: 50,
furniture: ['double_bed', 'sofa', 'wardrobe', 'chairs', 'nightstands', 'hanger', 'mirror'],
amenities: ['has_ac', 'has_tv', 'has_fridge', 'has_wifi', 'has_kettle', 'has_hairdryer', 'has_shower', 'has_sea_view'],
floors: [2],
max_guests: 4,
price_per_night: 4500,
image_path: 'img/1eae46658-cfca-4b65-82f0-e5868af5541b.webp',
extra_beds: 0,
extra_bed_price: 0
}
];
const ROOM_PRICES = {
@@ -38,10 +117,10 @@ const STATUS_LIST = [
];
const ROOM_TYPE_LIST = [
'2x-местный',
'3х-местный',
'Семейный',
'Люкс'
ROOM_TYPES.economy.id,
ROOM_TYPES.standard.id,
ROOM_TYPES.family.id,
ROOM_TYPES.vip.id
];
function getRoomPrice(roomType) {
@@ -60,14 +139,22 @@ function calculateBasePrice(roomType, checkin, checkout) {
return pricePerNight * nights;
}
function getRoomBasePriceByType(type) {
const room = DEFAULT_ROOMS.find(r => r.type === type);
return room ? room.price_per_night : 0;
}
module.exports = {
ROOM_TYPES,
FURNITURE_TYPES,
AMENITY_TYPES,
BOOKING_STATUSES,
DEFAULT_ROOMS,
ROOM_PRICES,
STATUS_LIST,
ROOM_TYPE_LIST,
getRoomPrice,
getRoomBasePriceByType,
calculateNights,
calculateBasePrice
};

View File

@@ -1,18 +1,140 @@
let db;
const path = require('path');
const fs = require('fs');
function init(database) {
db = database;
}
function parseRoomFields(row) {
if (!row) return null;
try { row.furniture = JSON.parse(row.furniture || '[]'); } catch { row.furniture = []; }
try { row.amenities = JSON.parse(row.amenities || '[]'); } catch { row.amenities = []; }
try { row.floors = JSON.parse(row.floors || '[]'); } catch { row.floors = []; }
return row;
}
function getAll(req, res) {
db.all(`SELECT * FROM rooms WHERE is_active = 1 ORDER BY price_per_guest ASC`, [], (err, rows) => {
db.all(`SELECT * FROM rooms WHERE is_active = 1 ORDER BY price_per_night ASC`, [], (err, rows) => {
if (err) { console.error('Rooms API error:', err); return res.status(500).json({ error: 'Database error' }); }
rows = rows.map(parseRoomFields);
res.json(rows);
});
}
function setupRoutes(app) {
function getAllForAdmin(req, res) {
db.all(`SELECT * FROM rooms ORDER BY price_per_night ASC`, [], (err, rows) => {
if (err) { console.error('Admin rooms API error:', err); return res.status(500).json({ error: 'Database error' }); }
rows = rows.map(parseRoomFields);
res.json(rows);
});
}
function createRoom(req, res) {
const { type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, extra_beds, extra_bed_price, is_active } = req.body;
if (!type || !name || !price_per_night) {
return res.status(400).json({ error: 'type, name и price_per_night обязательны' });
}
db.run(`INSERT INTO rooms (type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, extra_beds, extra_bed_price, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[type, name, description || '', rooms_count || 1, area_sqm || 20, max_guests || 2, JSON.stringify(furniture || []), JSON.stringify(amenities || []), JSON.stringify(floors || []), price_per_night, extra_beds || 0, extra_bed_price || 0, is_active !== undefined ? (is_active ? 1 : 0) : 1],
function(err) {
if (err) { console.error('Create room error:', err); return res.status(500).json({ error: 'Database error' }); }
db.get(`SELECT * FROM rooms WHERE id = ?`, [this.lastID], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.status(201).json(parseRoomFields(row));
});
}
);
}
function updateRoom(req, res) {
const { id } = req.params;
const { type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active } = req.body;
db.get(`SELECT * FROM rooms WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Номер не найден' });
db.run(`UPDATE rooms SET type = ?, name = ?, description = ?, rooms_count = ?, area_sqm = ?, max_guests = ?, furniture = ?, amenities = ?, floors = ?, price_per_night = ?, image_path = ?, extra_beds = ?, extra_bed_price = ?, is_active = ? WHERE id = ?`,
[
type ?? row.type,
name ?? row.name,
description ?? row.description,
rooms_count ?? row.rooms_count,
area_sqm ?? row.area_sqm,
max_guests ?? row.max_guests,
furniture ? JSON.stringify(furniture) : row.furniture,
amenities ? JSON.stringify(amenities) : row.amenities,
floors ? JSON.stringify(floors) : row.floors,
price_per_night ?? row.price_per_night,
image_path !== undefined ? image_path : row.image_path,
extra_beds ?? row.extra_beds,
extra_bed_price ?? row.extra_bed_price,
is_active !== undefined ? (is_active ? 1 : 0) : row.is_active,
id
],
function(err) {
if (err) { console.error('Update room error:', err); return res.status(500).json({ error: 'Database error' }); }
db.get(`SELECT * FROM rooms WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(parseRoomFields(row));
});
}
);
});
}
function deleteRoom(req, res) {
const { id } = req.params;
db.get(`SELECT * FROM rooms WHERE id = ?`, [id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'Номер не найден' });
db.run(`UPDATE rooms SET is_active = 0 WHERE id = ?`, [id], function(err) {
if (err) { console.error('Delete room error:', err); return res.status(500).json({ error: 'Database error' }); }
res.json({ message: 'Номер удалён' });
});
});
}
function uploadRoomImage(req, res) {
if (!req.file) {
return res.status(400).json({ error: 'Файл не загружен' });
}
let imagePath = 'uploads/rooms/' + req.file.filename;
if (req.file.mimetype !== 'image/webp') {
const inputPath = req.file.path;
const outputPath = path.join(path.dirname(inputPath), req.file.filename.replace(/\.[^.]+$/, '.webp'));
require('sharp')(inputPath)
.webp({ quality: 85 })
.toFile(outputPath)
.then(() => {
try { fs.unlinkSync(inputPath); } catch {}
imagePath = 'uploads/rooms/' + path.basename(outputPath);
res.json({ path: imagePath });
})
.catch(err => {
console.error('Image conversion error:', err);
res.json({ path: imagePath });
});
} else {
res.json({ path: imagePath });
}
}
function setupRoutes(app, authenticateToken, requireAdmin, upload) {
app.get('/api/rooms', getAll);
app.get('/api/admin/rooms', authenticateToken, requireAdmin, getAllForAdmin);
app.post('/api/admin/rooms', authenticateToken, requireAdmin, createRoom);
app.put('/api/admin/rooms/:id', authenticateToken, requireAdmin, updateRoom);
app.delete('/api/admin/rooms/:id', authenticateToken, requireAdmin, deleteRoom);
app.post('/api/admin/rooms/upload', authenticateToken, requireAdmin, upload.single('image'), uploadRoomImage);
}
module.exports = { init, setupRoutes };

View File

@@ -4,6 +4,7 @@
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"prom-client": "^15.1.3",
"sharp": "^0.34.5",
"sqlite3": "^6.0.1"

View File

@@ -177,6 +177,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<a href="#" class="active" data-tab="dashboard"><i class="fas fa-chart-pie"></i> Дашборд</a>
<a href="#" data-tab="users"><i class="fas fa-users"></i> Пользователи</a>
<a href="#" data-tab="bookings"><i class="fas fa-calendar-check"></i> Бронирования</a>
<a href="#" data-tab="rooms"><i class="fas fa-door-open"></i> Номера</a>
<a href="#" data-tab="promocodes"><i class="fas fa-ticket-alt"></i> Промокоды</a>
<a href="#" data-tab="reviews"><i class="fas fa-star"></i> Отзывы</a>
<a href="#" data-tab="settings"><i class="fas fa-cog"></i> Настройки</a>
@@ -285,6 +286,18 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
<div id="tab-rooms" class="tab-content">
<div class="top-bar">
<h1>Номера</h1>
<button class="btn-primary-custom admin-only" onclick="showRoomModal()"><i class="fas fa-plus"></i> Добавить номер</button>
</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>
</div>
</div>
<div id="tab-promocodes" class="tab-content">
<div class="top-bar">
<h1>Промокоды</h1>
@@ -541,6 +554,7 @@ function initTabs() {
if (tab === 'dashboard') loadDashboard();
if (tab === 'users') loadUsers();
if (tab === 'bookings') { bookingsLoaded = false; loadBookings(); }
if (tab === 'rooms') loadRooms();
if (tab === 'promocodes') loadPromocodes();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
@@ -935,6 +949,7 @@ async function deleteUser(id) {
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escAttr(s) { if (s === null || s === undefined) return ''; return String(s).replace(/'/g, "\\'").replace(/"/g, '&quot;'); }
async function changeComment(id, comment) {
try {
@@ -1183,6 +1198,366 @@ function toggleCodeShow() {
display.textContent = '******';
}
}
// ===== ROOMS MODULE =====
const ROOM_TYPES_LIST = ['2x-местный', '3х-местный', 'Семейный', 'Люкс'];
const FURNITURE_OPTIONS = [
{ id: 'double_bed', name: 'Двуспальная кровать' },
{ id: 'single_beds', name: 'Односпальные кровати' },
{ id: 'sofa', name: 'Диван' },
{ id: 'wardrobe', name: 'Шкаф' },
{ id: 'table', name: 'Стол' },
{ id: 'chairs', name: 'Стулья' },
{ id: 'nightstands', name: 'Прикроватные тумбочки' },
{ id: 'hanger', name: 'Вешалка' },
{ id: 'mirror', name: 'Зеркало' }
];
const AMENITY_OPTIONS = [
{ id: 'has_ac', name: 'Кондиционер', icon: 'snowflake' },
{ id: 'has_tv', name: 'Телевизор', icon: 'tv' },
{ id: 'has_fridge', name: 'Холодильник', icon: 'sink' },
{ id: 'has_wifi', name: 'Wi-Fi интернет', icon: 'wifi' },
{ id: 'has_kettle', name: 'Электрический чайник', icon: 'mug-hot' },
{ id: 'has_hairdryer', name: 'Фен', icon: 'wind' },
{ id: 'has_shower', name: 'Душ в номере', icon: 'shower' },
{ id: 'has_sea_view', name: 'Вид на море', icon: 'water' }
];
function getAmenityIcon(id) {
const opt = AMENITY_OPTIONS.find(a => a.id === id);
return opt ? opt.icon : 'check';
}
async function loadRooms() {
try {
const rooms = await api('/api/admin/rooms');
currentRooms = rooms;
renderRooms(rooms);
} catch(err) { showToast('Ошибка загрузки номеров: ' + err.message, 'error'); }
}
function renderRooms(rooms) {
const grid = document.getElementById('roomsGrid');
if (!rooms || rooms.length === 0) {
grid.innerHTML = '<div class="text-center text-muted" style="padding: 40px;">Нет номеров. Нажмите "Добавить номер" для создания.</div>';
return;
}
grid.innerHTML = rooms.map(r => {
const furniture = Array.isArray(r.furniture) ? r.furniture : [];
const amenities = Array.isArray(r.amenities) ? r.amenities : [];
const floors = Array.isArray(r.floors) ? r.floors : [];
const imageSrc = r.image_path ? (r.image_path.startsWith('uploads') ? '/' + r.image_path : r.image_path) : 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
const statusClass = r.is_active ? 'badge-status-оплачена' : 'badge-status-отменена';
const statusText = r.is_active ? 'Активен' : 'Скрыт';
const extraBedsText = r.extra_beds > 0 ? `+${r.extra_beds} доп. мест (${r.extra_bed_price}₽)` : '';
return '<div class="room-admin-card">' +
'<div class="room-admin-image">' +
'<img src="' + esc(imageSrc) + '" alt="' + esc(r.name) + '" onerror="this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EБез фото%3C/text%3E%3C/svg%3E\'">' +
'<div class="room-admin-type">' + esc(r.type) + '</div>' +
'<span class="badge badge-status ' + statusClass + '" style="position:absolute;top:10px;right:10px;font-size:0.65rem;">' + statusText + '</span>' +
'</div>' +
'<div class="room-admin-body">' +
'<h4 class="room-admin-name">' + esc(r.name) + '</h4>' +
'<div class="room-admin-price">' + r.price_per_night + ' ₽ <span>/ ночь</span></div>' +
'<div class="room-admin-meta">' +
'<span><i class="fas fa-door-open"></i> ' + (r.area_sqm || 0) + ' м²</span>' +
'<span><i class="fas fa-users"></i> до ' + r.max_guests + ' чел.</span>' +
'<span><i class="fas fa-bed"></i> ' + r.rooms_count + ' номеров</span>' +
'</div>' +
'<div class="room-admin-floors">Этажи: ' + floors.join(', ') + '</div>' +
'<div class="room-admin-amenities">' + amenities.map(a => '<span class="room-amenity-tag"><i class="fas fa-' + getAmenityIcon(a) + '"></i></span>').join('') + '</div>' +
(extraBedsText ? '<div class="room-admin-extra">' + extraBedsText + '</div>' : '') +
'<div class="room-admin-actions">' +
'<button class="btn-primary-custom btn-sm" onclick="showRoomModal(' + r.id + ')"><i class="fas fa-edit"></i> Редактировать</button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteRoom(' + r.id + ', \'' + escAttr(r.name) + '\')"><i class="fas fa-trash"></i></button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
let editingRoomId = null;
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() {
document.getElementById('roomModal').classList.remove('show');
document.body.style.overflow = '';
editingRoomId = null;
}
async function saveRoom() {
const name = document.getElementById('roomName').value.trim();
const type = document.getElementById('roomType').value;
const description = document.getElementById('roomDescription').value.trim();
const price_per_night = parseInt(document.getElementById('roomPrice').value) || 0;
const area_sqm = parseInt(document.getElementById('roomArea').value) || 20;
const max_guests = parseInt(document.getElementById('roomMaxGuests').value) || 2;
const rooms_count = parseInt(document.getElementById('roomCount').value) || 1;
const extra_beds = parseInt(document.getElementById('roomExtraBeds').value) || 0;
const extra_bed_price = parseInt(document.getElementById('roomExtraBedPrice').value) || 0;
const is_active = document.getElementById('roomIsActive').checked ? 1 : 0;
const floorsInput = document.getElementById('roomFloors').value.trim();
const floors = floorsInput ? floorsInput.split(',').map(f => parseInt(f.trim())).filter(f => !isNaN(f)) : [];
const furniture = [];
document.querySelectorAll('#roomForm input[name="furniture"]:checked').forEach(cb => furniture.push(cb.value));
const amenities = [];
document.querySelectorAll('#roomForm input[name="amenities"]:checked').forEach(cb => amenities.push(cb.value));
if (!name || !price_per_night) {
showToast('Название и цена обязательны', 'error');
return;
}
const image_path = document.getElementById('roomImagePreview').dataset.path || '';
try {
const data = {
type, name, description, price_per_night, area_sqm, max_guests, rooms_count,
floors, furniture, amenities, extra_beds, extra_bed_price, is_active
};
if (editingRoomId) {
await api('/api/admin/rooms/' + editingRoomId, { method: 'PUT', body: JSON.stringify({ ...data, image_path }) });
showToast('Номер обновлён');
} else {
await api('/api/admin/rooms', { method: 'POST', body: JSON.stringify(data) });
showToast('Номер создан');
}
closeRoomModal();
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function deleteRoom(id, name) {
if (!confirm('Удалить номер "' + name + '"? Это скроет номер из списка.')) return;
try {
await api('/api/admin/rooms/' + id, { method: 'DELETE' });
showToast('Номер удалён');
loadRooms();
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadRoomImage() {
const fileInput = document.getElementById('roomImageInput');
const file = fileInput.files[0];
if (!file) { showToast('Выберите файл', 'error'); return; }
const formData = new FormData();
formData.append('image', file);
try {
const res = await fetch(API + '/api/admin/rooms/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
document.getElementById('roomImagePreview').src = '/' + data.path;
document.getElementById('roomImagePreview').dataset.path = data.path;
showToast('Фото загружено');
} catch(err) { showToast(err.message, 'error'); }
}
</script>
<!-- Room Edit Modal -->
<div class="modal-backdrop-custom" id="roomModal">
<div class="modal-custom" style="max-width: 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="mb-3">
<label class="form-label">Название *</label>
<input type="text" class="form-control" id="roomName" required placeholder="2x-местный 1">
</div>
<div class="mb-3">
<label class="form-label">Тип номера *</label>
<select class="form-control" id="roomType">
<option value="2x-местный">2x-местный</option>
<option value="3х-местный">3х-местный</option>
<option value="Семейный">Семейный</option>
<option value="Люкс">Люкс</option>
</select>
</div>
</div>
<div class="mb-3">
<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="mb-3">
<label class="form-label">Цена/ночь (₽) *</label>
<input type="number" class="form-control" id="roomPrice" required min="0" placeholder="1500">
</div>
<div class="mb-3">
<label class="form-label">Площадь (м²)</label>
<input type="number" class="form-control" id="roomArea" min="1" placeholder="20">
</div>
<div class="mb-3">
<label class="form-label">Макс. гостей</label>
<input type="number" class="form-control" id="roomMaxGuests" min="1" max="10" placeholder="2">
</div>
<div class="mb-3">
<label class="form-label">Кол-во номеров</label>
<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="mb-3">
<label class="form-label">Этажи</label>
<input type="text" class="form-control" id="roomFloors" placeholder="1, 2, 3">
</div>
<div class="mb-3">
<label class="form-label">Доп. места (макс)</label>
<input type="number" class="form-control" id="roomExtraBeds" min="0" max="50" placeholder="0">
</div>
<div class="mb-3">
<label class="form-label">Цена доп. места (₽)</label>
<input type="number" class="form-control" id="roomExtraBedPrice" min="0" placeholder="1000">
</div>
</div>
<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;">
<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>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG, PNG, WebP до 5 МБ. Автоконвертация в WebP.</small>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Мебель</label>
<div 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>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_wardrobe" name="furniture" value="wardrobe"><label class="form-check-label" for="furn_wardrobe">Шкаф</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_table" name="furniture" value="table"><label class="form-check-label" for="furn_table">Стол</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_chairs" name="furniture" value="chairs"><label class="form-check-label" for="furn_chairs">Стулья</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_nightstands" name="furniture" value="nightstands"><label class="form-check-label" for="furn_nightstands">Прикроватные тумбочки</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_hanger" name="furniture" value="hanger"><label class="form-check-label" for="furn_hanger">Вешалка</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="furn_mirror" name="furniture" value="mirror"><label class="form-check-label" for="furn_mirror">Зеркало</label></div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Удобства</label>
<div 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>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_wifi" name="amenities" value="has_wifi"><label class="form-check-label" for="am_has_wifi">📶 Wi-Fi</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_kettle" name="amenities" value="has_kettle"><label class="form-check-label" for="am_has_kettle">☕ Чайник</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_hairdryer" name="amenities" value="has_hairdryer"><label class="form-check-label" for="am_has_hairdryer">💨 Фен</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_shower" name="amenities" value="has_shower"><label class="form-check-label" for="am_has_shower">🚿 Душ в номере</label></div>
<div class="form-check"><input type="checkbox" class="form-check-input" id="am_has_sea_view" name="amenities" value="has_sea_view"><label class="form-check-label" for="am_has_sea_view">🌊 Вид на море</label></div>
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="roomIsActive" checked>
<label class="form-check-label" for="roomIsActive">Номер активен (виден на сайте)</label>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="closeRoomModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveRoom()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
<style>
.room-admin-card { background: #fff; border-radius: 16px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); border: 1px solid #e2e8f0; transition: all 0.2s; }
.room-admin-card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.12); transform: translateY(-2px); }
.room-admin-image { position: relative; height: 180px; background: #1e293b; overflow: hidden; }
.room-admin-image img { width: 100%; height: 100%; object-fit: cover; }
.room-admin-type { position: absolute; top: 10px; left: 10px; background: rgba(37,99,235,0.9); color: #fff; padding: 4px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; }
.room-admin-body { padding: 16px; }
.room-admin-name { font-size: 1.1rem; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
.room-admin-price { font-size: 1.4rem; font-weight: 700; color: #c9a84c; font-family: 'Playfair Display', serif; }
.room-admin-price span { font-size: 0.8rem; color: #94a3b8; font-weight: 400; }
.room-admin-meta { display: flex; gap: 12px; font-size: 0.8rem; color: #64748b; margin: 8px 0; }
.room-admin-meta span { display: flex; align-items: center; gap: 4px; }
.room-admin-meta i { color: #94a3b8; width: 14px; }
.room-admin-floors { font-size: 0.75rem; color: #94a3b8; margin-bottom: 8px; }
.room-admin-amenities { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.room-amenity-tag { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: #f1f5f9; border-radius: 6px; font-size: 0.75rem; color: #64748b; }
.room-amenity-tag i { font-size: 0.7rem; }
.room-admin-extra { font-size: 0.75rem; color: #e67e22; background: #fef3e2; padding: 4px 8px; border-radius: 4px; margin-bottom: 10px; display: inline-block; }
.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-danger-custom { padding: 8px 12px; }
</style>
</body>
</html>

View File

@@ -558,6 +558,14 @@ h1, h2, h3, h4 {
background: #fef3e2;
border-radius: 6px;
}
.room-extra-hint {
font-size: 0.75rem;
color: #e67e22;
margin-bottom: 12px;
padding: 6px 10px;
background: #fef3e2;
border-radius: 6px;
}
.room-feature-tag {
display: inline-flex;
align-items: center;
@@ -1027,6 +1035,10 @@ h1, h2, h3, h4 {
transform: translateY(40px);
transition: all 0.8s ease;
}
.animate-on-scroll.visible {
opacity: 1;
transform: translateY(0);
}
.animate-on-scroll.animated {
opacity: 1;
transform: translateY(0);

View File

@@ -162,143 +162,12 @@
<div class="abkhazian-pattern"></div>
<p class="section-subtitle">Четыре категории номеров — от 2x-местного до люкса. Каждый номер оснащён всем необходимым для идеального отдыха.</p>
</div>
<div class="row g-4">
<!-- 2x-местный -->
<div class="col-lg-4 animate-on-scroll">
<div class="room-card">
<div class="room-image">
<img src="img/1faae356b-9f79-489d-8165-c37a47b82040.webp" alt="2x-местный номер">
<div class="room-category">Стандарт</div>
</div>
<div class="room-body">
<h3 class="room-name">2x-местный</h3>
<p class="room-desc">Уютный номер на двоих с одной или двумя кроватями. Идеально для пары или друзей. Кондиционер, TV, холодильник, Wi-Fi.</p>
<div class="room-meta">
<span><i class="fas fa-door-open"></i> 20 м²</span>
<span><i class="fas fa-layer-group"></i> Этажи 1-3</span>
<span><i class="fas fa-shower"></i> Душ в номере</span>
</div>
<div class="room-features">
<span class="room-feature-tag"><i class="fas fa-bed"></i> 2 кровати</span>
<span class="room-feature-tag"><i class="fas fa-snowflake"></i> Кондиционер</span>
<span class="room-feature-tag"><i class="fas fa-tv"></i> ТВ</span>
<span class="room-feature-tag"><i class="fas fa-wifi"></i> WiFi</span>
<span class="room-feature-tag"><i class="fas fa-sink"></i> Холодильник</span>
<span class="room-feature-tag"><i class="fas fa-mug-hot"></i> Чайник</span>
</div>
<div class="room-footer">
<div class="room-price">
<span class="amount">от 1 500 ₽</span>
<span class="period"> / ночь</span>
</div>
<button class="btn-book" data-bs-toggle="modal" data-bs-target="#bookingModal" data-room="2x-местный">Забронировать</button>
</div>
</div>
</div>
</div>
<!-- 3х-местный -->
<div class="col-lg-4 animate-on-scroll">
<div class="room-card">
<div class="room-image">
<img src="img/1006bc9be-64c6-43e3-9625-1a862d04930c.webp" alt="3х-местный номер">
<div class="room-category">Стандарт</div>
</div>
<div class="room-body">
<h3 class="room-name">3х-местный</h3>
<p class="room-desc">Комфортный трёхместный номер с односпальными или двуспальной кроватью. Просторный и светлый с видом на море.</p>
<div class="room-meta">
<span><i class="fas fa-door-open"></i> 20 м²</span>
<span><i class="fas fa-layer-group"></i> Этажи 1-3</span>
<span><i class="fas fa-shower"></i> Душ в номере</span>
</div>
<div class="room-features">
<span class="room-feature-tag"><i class="fas fa-bed"></i> 3 кровати</span>
<span class="room-feature-tag"><i class="fas fa-snowflake"></i> Кондиционер</span>
<span class="room-feature-tag"><i class="fas fa-tv"></i> ТВ</span>
<span class="room-feature-tag"><i class="fas fa-wifi"></i> WiFi</span>
<span class="room-feature-tag"><i class="fas fa-sink"></i> Холодильник</span>
<span class="room-feature-tag"><i class="fas fa-mug-hot"></i> Чайник</span>
<span class="room-feature-tag"><i class="fas fa-sea"></i> Вид на море</span>
</div>
<div class="room-footer">
<div class="room-price">
<span class="amount">от 2 000 ₽</span>
<span class="period"> / ночь</span>
</div>
<button class="btn-book" data-bs-toggle="modal" data-bs-target="#bookingModal" data-room="3х-местный">Забронировать</button>
</div>
</div>
</div>
</div>
<!-- Семейный -->
<div class="col-lg-4 animate-on-scroll">
<div class="room-card featured">
<div class="room-image">
<img src="img/1eae46658-cfca-4b65-82f0-e5868af5541b.webp" alt="Семейный номер">
<div class="room-category">Семейный</div>
</div>
<div class="room-body">
<h3 class="room-name">Семейный</h3>
<p class="room-desc">Просторный двухкомнатный номер для семьи до 4 человек. Две спальни с двуспальными кроватями. Возможны дополнительные места.</p>
<div class="room-meta">
<span><i class="fas fa-door-open"></i> 40 м²</span>
<span><i class="fas fa-layer-group"></i> Этаж 2</span>
<span><i class="fas fa-shower"></i> Душ в номере</span>
</div>
<div class="room-features">
<span class="room-feature-tag"><i class="fas fa-bed"></i> 4 кровати</span>
<span class="room-feature-tag"><i class="fas fa-door-open"></i> 2 комнаты</span>
<span class="room-feature-tag"><i class="fas fa-snowflake"></i> Кондиционер</span>
<span class="room-feature-tag"><i class="fas fa-tv"></i> ТВ</span>
<span class="room-feature-tag"><i class="fas fa-wifi"></i> WiFi</span>
<span class="room-feature-tag"><i class="fas fa-sink"></i> Холодильник</span>
<span class="room-feature-tag"><i class="fas fa-mug-hot"></i> Чайник</span>
</div>
<div class="room-footer">
<div class="room-price">
<span class="amount">от 3 000 ₽</span>
<span class="period"> / ночь</span>
</div>
<button class="btn-book" data-bs-toggle="modal" data-bs-target="#bookingModal" data-room="Семейный">Забронировать</button>
</div>
</div>
</div>
</div>
<!-- Люкс -->
<div class="col-lg-4 animate-on-scroll">
<div class="room-card">
<div class="room-image">
<img src="img/1eae46658-cfca-4b65-82f0-e5868af5541b.webp" alt="Люкс">
<div class="room-category">Люкс</div>
</div>
<div class="room-body">
<h3 class="room-name">Люкс</h3>
<p class="room-desc">Элегантный двухкомнатный люкс с двуспальной кроватью, гостиной зоной и великолепным видом на море. Премиум-комфорт.</p>
<div class="room-meta">
<span><i class="fas fa-door-open"></i> 50 м²</span>
<span><i class="fas fa-layer-group"></i> Этаж 2</span>
<span><i class="fas fa-shower"></i> Душ в номере</span>
</div>
<div class="room-features">
<span class="room-feature-tag"><i class="fas fa-bed"></i> 4 кровати</span>
<span class="room-feature-tag"><i class="fas fa-door-open"></i> 2 комнаты</span>
<span class="room-feature-tag"><i class="fas fa-snowflake"></i> Кондиционер</span>
<span class="room-feature-tag"><i class="fas fa-tv"></i> ТВ</span>
<span class="room-feature-tag"><i class="fas fa-wifi"></i> WiFi</span>
<span class="room-feature-tag"><i class="fas fa-sink"></i> Холодильник</span>
<span class="room-feature-tag"><i class="fas fa-mug-hot"></i> Чайник</span>
<span class="room-feature-tag"><i class="fas fa-sea"></i> Вид на море</span>
</div>
<div class="room-footer">
<div class="room-price">
<span class="amount">от 4 500 ₽</span>
<span class="period"> / ночь</span>
</div>
<button class="btn-book" data-bs-toggle="modal" data-bs-target="#bookingModal" data-room="Люкс">Забронировать</button>
</div>
</div>
</div>
</div>
<div id="roomsGrid" class="row g-4"></div>
<div id="roomsEmpty" style="display: none; text-align: center; padding: 40px; color: #94a3b8;">
<!--
<i class="fas fa-door-open" style="font-size: 3rem; margin-bottom: 16px;"></i>
<p>Номера скоро появятся</p>
-->
</div>
</div>
</section>
@@ -666,6 +535,7 @@
</div>
<script src="js/bootstrap.bundle.min.js"></script>
<script src="js/rooms-public.js"></script>
<script src="js/main.js"></script>
<script src="js/i18n.js"></script>
<script src="js/reviews.js"></script>

View File

@@ -71,15 +71,48 @@ document.querySelectorAll('.btn-book').forEach(btn => {
btn.addEventListener('click', function() {
const room = this.getAttribute('data-room');
document.getElementById('selectedRoom').value = room;
updateGuestOptions(room);
const maxGuests = parseInt(this.getAttribute('data-max-guests')) || ROOM_MAX_GUESTS[room] || 4;
updateGuestOptionsFallback(room);
hidePriceInfo();
});
});
function updateGuestOptionsDynamic(roomType, maxGuests) {
const guestsSelect = document.querySelector('[name="guests"]');
if (!guestsSelect) return;
let options = [];
for (let i = 1; i <= maxGuests; i++) {
let text = i === 1 ? '1 гость' : (i < 5 ? `${i} гостя` : `${i} гостей`);
options.push(`<option value="${i}">${text}</option>`);
}
guestsSelect.innerHTML = options.join('');
}
window.updateRoomPricesData = function(prices, maxGuests) {
Object.assign(ROOM_PRICES, prices);
Object.assign(ROOM_MAX_GUESTS, maxGuests);
};
function updateGuestOptions(room) {
const guestsSelect = document.querySelector('[name="guests"]');
if (!guestsSelect) return;
const maxGuests = ROOM_MAX_GUESTS[room] || 4;
let options = [];
for (let i = 1; i <= maxGuests; i++) {
let text = i === 1 ? '1 гость' : (i < 5 ? `${i} гостя` : `${i} гостей`);
options.push(`<option value="${i}">${text}</option>`);
}
guestsSelect.innerHTML = options.join('');
}
function updateGuestOptionsFallback(room) {
const guestsSelect = document.querySelector('[name="guests"]');
if (!guestsSelect) return;
const options2x = [
{ value: 1, text: '1 гость' },
{ value: 2, text: '2 гостя' }
@@ -105,8 +138,11 @@ function updateGuestOptions(room) {
guestsSelect.innerHTML = options.map(o => `<option value="${o.value}">${o.text}</option>`).join('');
}
function hideGuestOptionsUpdate() {}
// Price calculation
const ROOM_PRICES = { '2x-местный': 1500, '3х-местный': 2000, 'Семейный': 3000, 'Люкс': 4500 };
const ROOM_MAX_GUESTS = { '2x-местный': 2, '3х-местный': 3, 'Семейный': 4, 'Люкс': 4 };
let currentPromocodeData = null;
function calculateNights(checkin, checkout) {

130
public/js/rooms-public.js Normal file
View File

@@ -0,0 +1,130 @@
const AMENITY_ICONS = {
has_ac: { icon: 'fa-snowflake', label: 'Кондиционер' },
has_tv: { icon: 'fa-tv', label: 'ТВ' },
has_fridge: { icon: 'fa-sink', label: 'Холодильник' },
has_wifi: { icon: 'fa-wifi', label: 'WiFi' },
has_kettle: { icon: 'fa-mug-hot', label: 'Чайник' },
has_hairdryer: { icon: 'fa-wind', label: 'Фен' },
has_shower: { icon: 'fa-shower', label: 'Душ в номере' },
has_sea_view: { icon: 'fa-water', label: 'Вид на море' }
};
let cachedRooms = [];
async function loadRoomsPublic() {
try {
const res = await fetch('/api/rooms');
if (!res.ok) throw new Error('Failed to load rooms');
cachedRooms = await res.json();
renderRoomsPublic(cachedRooms);
updateRoomPrices(cachedRooms);
} catch (err) {
console.error('Error loading rooms:', err);
document.getElementById('roomsEmpty').style.display = 'block';
}
}
function renderRoomsPublic(rooms) {
const grid = document.getElementById('roomsGrid');
const empty = document.getElementById('roomsEmpty');
if (!rooms || rooms.length === 0) {
grid.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
grid.innerHTML = rooms.map((room, index) => {
const amenities = Array.isArray(room.amenities) ? room.amenities : [];
const floors = Array.isArray(room.floors) ? room.floors : [];
const imageSrc = room.image_path || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="16" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EФото%3C/text%3E%3C/svg%3E';
const fullImageSrc = imageSrc.startsWith('uploads') ? '/' + imageSrc : imageSrc;
const amenitiesHtml = amenities.map(a => {
const info = AMENITY_ICONS[a];
if (!info) return '';
return `<span class="room-feature-tag"><i class="fas ${info.icon}"></i> ${info.label}</span>`;
}).join('');
const floorsStr = floors.length > 0 ? 'Этажи ' + floors.join(', ') : '';
const extraBedsHtml = room.extra_beds > 0
? `<div class="room-extra-hint">Возможно +${room.extra_beds} доп. мест (${room.extra_bed_price} ₽)</div>`
: '';
const featuredClass = room.type === 'Семейный' ? ' featured' : '';
return `
<div class="col-lg-4">
<div class="room-card${featuredClass}">
<div class="room-image">
<img src="${fullImageSrc}" alt="${room.name}"
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EФото%3C/text%3E%3C/svg%3E'">
<div class="room-category">${room.type}</div>
</div>
<div class="room-body">
<h3 class="room-name">${escapeHtml(room.name)}</h3>
<p class="room-desc">${escapeHtml(room.description || '')}</p>
<div class="room-meta">
<span><i class="fas fa-door-open"></i> ${room.area_sqm || 20} м²</span>
${floorsStr ? `<span><i class="fas fa-layer-group"></i> ${floorsStr}</span>` : ''}
${amenities.includes('has_shower') ? '<span><i class="fas fa-shower"></i> Душ в номере</span>' : ''}
</div>
<div class="room-features">
${amenitiesHtml}
</div>
${extraBedsHtml}
<div class="room-footer">
<div class="room-price">
<span class="amount">от ${room.price_per_night || 0} ₽</span>
<span class="period"> / ночь</span>
</div>
<button class="btn-book" data-bs-toggle="modal" data-bs-target="#bookingModal" data-room="${room.type}" data-max-guests="${room.max_guests}">Забронировать</button>
</div>
</div>
</div>
</div>
`;
}).join('');
initRoomBookingHandlers();
}
function initRoomBookingHandlers() {
document.querySelectorAll('.btn-book').forEach(btn => {
btn.addEventListener('click', function() {
const room = this.getAttribute('data-room');
const maxGuests = parseInt(this.getAttribute('data-max-guests')) || 4;
document.getElementById('selectedRoom').value = room;
updateGuestOptionsDynamic(room, maxGuests);
hidePriceInfo();
});
});
}
function updateGuestOptionsDynamic(roomType, maxGuests) {
const guestsSelect = document.querySelector('[name="guests"]');
if (!guestsSelect) return;
let options = [];
for (let i = 1; i <= maxGuests; i++) {
let text = i === 1 ? '1 гость' : (i < 5 ? `${i} гостя` : `${i} гостей`);
options.push(`<option value="${i}">${text}</option>`);
}
guestsSelect.innerHTML = options.join('');
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.addEventListener('DOMContentLoaded', () => {
loadRoomsPublic();
});

153
server.js
View File

@@ -6,6 +6,7 @@ const sharp = require('sharp');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const client = require('prom-client');
const multer = require('multer');
require('dotenv').config();
const config = require('./config');
@@ -19,6 +20,31 @@ const JWT_SECRET = process.env.JWT_SECRET;
const MONITORING_USER = process.env.MONITORING_USER || 'monitoring';
const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123';
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const roomsUploadsDir = path.join(uploadsDir, 'rooms');
if (!fs.existsSync(roomsUploadsDir)) fs.mkdirSync(roomsUploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, roomsUploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const timestamp = Date.now();
cb(null, `${timestamp}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowed.includes(ext)) cb(null, true);
else cb(new Error('Только изображения: jpg, jpeg, png, webp'));
}
});
if (!JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable not set');
process.exit(1);
@@ -94,6 +120,7 @@ app.use((req, res, next) => {
next();
});
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use((req, res, next) => {
const start = Date.now();
@@ -224,19 +251,95 @@ db.serialize(() => {
name TEXT NOT NULL,
description TEXT,
rooms_count INTEGER DEFAULT 1,
single_beds INTEGER DEFAULT 0,
double_beds INTEGER DEFAULT 0,
has_sofa INTEGER DEFAULT 0,
has_ac INTEGER DEFAULT 0,
has_wifi INTEGER DEFAULT 0,
has_shower INTEGER DEFAULT 0,
area_sqm INTEGER DEFAULT 20,
max_guests INTEGER DEFAULT 2,
price_per_guest INTEGER NOT NULL,
furniture TEXT DEFAULT '[]',
amenities TEXT DEFAULT '[]',
floors TEXT DEFAULT '[]',
price_per_night INTEGER NOT NULL,
image_path TEXT,
extra_beds INTEGER DEFAULT 0,
extra_bed_price INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupBookingHistoryTable();
db.all("PRAGMA table_info(rooms)", [], (err, cols) => {
if (err) { setupBookingHistoryTable(); return; }
const colNames = cols.map(c => c.name);
const migrations = [
['area_sqm', 'INTEGER DEFAULT 20'],
['furniture', "TEXT DEFAULT '[]'"],
['amenities', "TEXT DEFAULT '[]'"],
['floors', "TEXT DEFAULT '[]'"],
['extra_beds', 'INTEGER DEFAULT 0'],
['extra_bed_price', 'INTEGER DEFAULT 0']
];
let pending = migrations.length;
if (pending === 0) {
migratePricePerNight();
return;
}
migrations.forEach(([colName, colDef], i) => {
if (!colNames.includes(colName)) {
db.run(`ALTER TABLE rooms ADD COLUMN ${colName} ${colDef}`, (err) => {
if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) {
console.log('Room migration note:', err.message);
}
});
}
if (--pending === 0) migratePricePerNight();
});
});
}
function migratePricePerNight() {
db.all("PRAGMA table_info(rooms)", [], (err, cols) => {
if (err) { setupBookingHistoryTable(); return; }
const colNames = cols.map(c => c.name);
if (!colNames.includes('price_per_night')) {
db.run(`ALTER TABLE rooms ADD COLUMN price_per_night INTEGER DEFAULT 0`, (err) => {
if (err && !err.message.includes('duplicate') && !err.message.includes('NOT NULL')) {
console.log('Room migration note (add price_per_night):', err.message);
}
});
}
db.run(`CREATE TABLE rooms_backup AS SELECT id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at FROM rooms`, (err) => {
if (err) {
console.log('Room backup failed:', err.message);
setupBookingHistoryTable();
return;
}
db.run(`DROP TABLE rooms`, (err) => {
if (err) {
console.log('Room drop failed:', err.message);
setupBookingHistoryTable();
return;
}
db.run(`CREATE TABLE rooms (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, name TEXT NOT NULL, description TEXT, rooms_count INTEGER DEFAULT 1, area_sqm INTEGER DEFAULT 20, max_guests INTEGER DEFAULT 2, furniture TEXT DEFAULT '[]', amenities TEXT DEFAULT '[]', floors TEXT DEFAULT '[]', price_per_night INTEGER NOT NULL, image_path TEXT, extra_beds INTEGER DEFAULT 0, extra_bed_price INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`, (err) => {
if (err) {
console.log('Room recreate failed:', err.message);
setupBookingHistoryTable();
return;
}
db.run(`INSERT INTO rooms (id, type, name, description, rooms_count, area_sqm, max_guests, furniture, amenities, floors, price_per_night, image_path, extra_beds, extra_bed_price, is_active, created_at) SELECT id, type, name, description, rooms_count, area_sqm, max_guests, COALESCE(furniture, '[]'), COALESCE(amenities, '[]'), COALESCE(floors, '[]'), COALESCE(price_per_night, 0) as price_per_night, image_path, COALESCE(extra_beds, 0), COALESCE(extra_bed_price, 0), is_active, created_at FROM rooms_backup`, (err) => {
if (err) console.log('Room data restore failed:', err.message);
db.run(`DROP TABLE rooms_backup`, (err) => {});
setupBookingHistoryTable();
});
});
});
});
});
}
function setupBookingHistoryTable() {
@@ -363,8 +466,27 @@ function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
if (err) return console.error('Check rooms count error:', err);
if (row.count > 0) return;
const stmt = db.prepare(`INSERT INTO rooms (type, name, description, rooms_count, single_beds, double_beds, has_sofa, has_ac, has_wifi, has_shower, max_guests, price_per_guest, image_path, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
config.DEFAULT_ROOMS.forEach(r => stmt.run(r.type, r.name, r.description, r.rooms_count, r.single_beds, r.double_beds, r.has_sofa, r.has_ac, r.has_wifi, r.has_shower, r.max_guests, r.price_per_guest, r.image_path, r.is_active));
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 => {
stmt.run(
r.type,
r.name,
r.description,
r.rooms_count,
r.area_sqm,
r.max_guests,
JSON.stringify(r.furniture || []),
JSON.stringify(r.amenities || []),
JSON.stringify(r.floors || []),
r.price_per_night,
r.image_path,
r.extra_beds || 0,
r.extra_bed_price || 0
);
});
stmt.finalize();
console.log('✅ Default rooms initialized');
});
@@ -402,7 +524,7 @@ authModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmi
bookingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
adminBookingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
promocodesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
roomsModule.setupRoutes(app);
roomsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, upload);
usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
@@ -479,6 +601,15 @@ app.get('/api/cities/:countryCode', (req, res) => {
}
});
app.get('/uploads/rooms/:filename', (req, res) => {
const filePath = path.join(roomsUploadsDir, req.params.filename);
if (fs.existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.status(404).json({ error: 'File not found' });
}
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB