рус3
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 32s

This commit is contained in:
2026-07-18 22:33:14 +05:00
parent bdb30c9b20
commit 100fc38383
8 changed files with 618 additions and 34 deletions

205
modules/activities/index.js Normal file
View File

@@ -0,0 +1,205 @@
const path = require('path');
const fs = require('fs');
let db;
function init(database) {
db = database;
}
function getUploadsDir() {
const dir = path.join(__dirname, '..', '..', 'data', 'activity_images');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
function validLangCount(row) {
let count = 0;
if (row.title_ru || row.desc_ru) count++;
if (row.title_en || row.desc_en) count++;
if (row.title_ab || row.desc_ab) count++;
return count;
}
function pickLang(row, lang) {
const filled = validLangCount(row);
if (filled === 1) {
if (row.title_ru || row.desc_ru) return { title: row.title_ru, desc: row.desc_ru };
if (row.title_en || row.desc_en) return { title: row.title_en, desc: row.desc_en };
return { title: row.title_ab, desc: row.desc_ab };
}
return {
title: row['title_' + lang] || row.title_ru || '',
desc: row['desc_' + lang] || row.desc_ru || ''
};
}
function getPublicActivities(req, res) {
const lang = req.query.lang || 'ru';
db.all(
`SELECT id, title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path
FROM activities WHERE is_active = 1 ORDER BY sort_order ASC, id ASC`,
[],
(err, rows) => {
if (err) {
console.error('Get activities error:', err);
return res.status(500).json({ error: 'Database error' });
}
const activities = rows.map(row => {
const localized = pickLang(row, lang);
return {
id: row.id,
title: localized.title,
desc: localized.desc,
icon: row.icon,
image_path: row.image_path
};
});
res.json(activities);
}
);
}
function getAdminActivities(req, res) {
db.all(
`SELECT * FROM activities ORDER BY sort_order ASC, id ASC`,
[],
(err, rows) => {
if (err) {
console.error('Get admin activities error:', err);
return res.status(500).json({ error: 'Database error' });
}
rows.forEach(row => {
row.created_at = row.created_at ? new Date(row.created_at).toISOString() : null;
});
res.json(rows);
}
);
}
function createActivity(req, res) {
const { title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path, sort_order } = req.body;
const rawRow = { title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab };
const count = validLangCount(rawRow);
if (count !== 1 && count !== 3) {
return res.status(400).json({ error: 'Должен быть заполнен 1 язык или все 3' });
}
db.run(
`INSERT INTO activities (title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path, sort_order, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[title_ru || null, title_en || null, title_ab || null, desc_ru || null, desc_en || null, desc_ab || null, icon || null, image_path || null, sort_order || 0],
function(err) {
if (err) {
console.error('Create activity error:', err);
return res.status(500).json({ error: 'Database error' });
}
res.status(201).json({ message: 'Activity created', id: this.lastID });
}
);
}
function updateActivity(req, res) {
const id = parseInt(req.params.id);
const langFields = ['title_ru', 'title_en', 'title_ab', 'desc_ru', 'desc_en', 'desc_ab'];
const anyLangChanged = langFields.some(f => req.body[f] !== undefined);
if (anyLangChanged) {
const rawRow = {};
langFields.forEach(f => { rawRow[f] = req.body[f]; });
const count = validLangCount(rawRow);
if (count !== 0 && count !== 1 && count !== 3) {
return res.status(400).json({ error: 'Должен быть заполнен 1 язык или все 3' });
}
}
const allowed = ['title_ru', 'title_en', 'title_ab', 'desc_ru', 'desc_en', 'desc_ab', 'icon', 'image_path', 'sort_order', 'is_active'];
const fields = [];
const values = [];
allowed.forEach(f => {
if (req.body[f] !== undefined) {
fields.push(f + ' = ?');
values.push(req.body[f] === '' ? null : req.body[f]);
}
});
if (!fields.length) {
return res.status(400).json({ error: 'No fields to update' });
}
values.push(id);
db.run(
`UPDATE activities SET ${fields.join(', ')} WHERE id = ?`,
values,
function(err) {
if (err) {
console.error('Update activity error:', err);
return res.status(500).json({ error: 'Database error' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Activity not found' });
}
res.json({ message: 'Activity updated', id });
}
);
}
function deleteActivity(req, res) {
const id = parseInt(req.params.id);
db.run(`DELETE FROM activities WHERE id = ?`, [id], function(err) {
if (err) {
console.error('Delete activity error:', err);
return res.status(500).json({ error: 'Database error' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Activity not found' });
}
res.json({ message: 'Activity deleted', id });
});
}
function uploadActivityImage(req, res) {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const filename = req.file.filename;
const activityId = parseInt(req.params.id);
const imagePath = 'data/activity_images/' + filename;
if (activityId && req.body.update_activity === '1') {
db.run(`UPDATE activities SET image_path = ? WHERE id = ?`, [imagePath, activityId], function(err) {
if (err) {
console.error('Update activity image error:', err);
return res.status(500).json({ error: 'Database error' });
}
});
}
res.json({ message: 'Image uploaded', path: imagePath, filename });
}
function setupRoutes(app, authenticateToken, requireAdmin, upload) {
app.get('/api/activities', getPublicActivities);
app.get('/api/admin/activities', authenticateToken, getAllActivities);
app.post('/api/admin/activities', authenticateToken, createActivity);
app.put('/api/admin/activities/:id', authenticateToken, updateActivity);
app.delete('/api/admin/activities/:id', authenticateToken, deleteActivity);
app.post('/api/admin/activities/upload', authenticateToken, upload.single('image'), uploadActivityImage);
app.post('/api/admin/activities/:id/image', authenticateToken, upload.single('image'), uploadActivityImage);
}
function getAllActivities(req, res) {
getAdminActivities(req, res);
}
module.exports = { init, setupRoutes, getUploadsDir };

View File

@@ -259,6 +259,52 @@ function initDatabase(db) {
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
setupActivitiesTable();
}
function setupActivitiesTable() {
db.run(`CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title_ru TEXT,
title_en TEXT,
title_ab TEXT,
desc_ru TEXT,
desc_en TEXT,
desc_ab TEXT,
icon TEXT,
image_path TEXT,
sort_order INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, () => {
db.get("SELECT COUNT(*) as count FROM activities", [], (err, row) => {
if (err || row.count > 0) return;
const stmt = db.prepare(`INSERT INTO activities (title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
stmt.run(
'SUP-борды', 'SUP Boards', 'SUP-дкақәа',
'Аренда SUP-бордов прямо на пляже. Исследуйте побережье с воды — идеальный способ увидеть Абхазию с нового ракурса!',
'Rent SUP-boards right on the beach. Explore the coast from the water — the perfect way to see Abkhazia from a new angle!',
'SUP-дкақәа рендара аԥшаҳәаҟны иара убас. Аӡы аҟнытәи аԥшаҳәа аҭҵаара — Аԥсны алахреи ала аҿаԥшра аидеалтә мҩа!',
'fas fa-water', 'img/105d059e5-a204-499f-a01c-c540f6ae1e5d.webp', 1
);
stmt.run(
'Кафе у пляжа', 'Beach Cafe', 'Акафе аԥшаҳәаҟны',
'Всего 2 минуты пешком — уютное кафе с видом на море. Абхазская и русская кухня, свежие морепродукты и ароматный кофе.',
'Just a 2-minute walk — a cozy cafe with a sea view. Abkhazian and Russian cuisine, fresh seafood and aromatic coffee.',
'2 минуҭк мацара ашьааршәара — акафе ҟәышқәа амшын ахсаала ала. Аԥсуеи аурыси акухниа, амшынтәи аԥсыӡқәа ҿыцқәеи акафе иҭагаз.',
'fas fa-coffee', 'img/18abbb136-0e24-4c92-a568-fec8011e029b.webp', 2
);
stmt.run(
'Столовая Hotel 777', 'Hotel 777 Canteen', 'Астоловаиа Hotel 777',
'Домашняя кухня каждый день! Завтраки, обеды и ужины из свежих местных продуктов. Аджика, хачапури, азу и многое другое.',
'Home cooking every day! Breakfast, lunch and dinner made from fresh local ingredients. Adjika, khachapuri, azu and much more.',
'Аҩнытәи акухниа есымша! Ашьажьыкра, аинышәареи ахархәареи аҭыԥантәи аԥсыӡқәа ҿыцқәа ирылхуп. Аџьыка, ахачаԥури, азуи даҽагьы.',
'fas fa-utensils', 'img/1f00325ba-df2c-4bb9-9ec6-28958e11f843.webp', 3
);
stmt.finalize();
console.log('✅ Default activities initialized');
});
});
setupBackupsTable();
}

View File

@@ -206,6 +206,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<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="seasonal"><i class="fas fa-tags"></i> Сезонные цены</a>
<a href="#" data-tab="activities"><i class="fas fa-umbrella-beach"></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>
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
@@ -598,6 +599,18 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
</div>
<div id="tab-activities" class="tab-content">
<div class="top-bar">
<h1>Развлечения — Чем заняться рядом</h1>
<button class="btn-gold" onclick="showActivityModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<div id="activitiesGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
</div>
</div>
</div>
</div>
</div>
@@ -731,6 +744,8 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
const API = '';
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');
let editingActivityId = null;
let tempActivityImagePath = null;
function getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }
@@ -766,6 +781,7 @@ function initTabs() {
if (tab === 'rooms') loadRooms();
if (tab === 'promocodes') loadPromocodes();
if (tab === 'seasonal') loadSeasonalPrices();
if (tab === 'activities') loadActivitiesAdmin();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
@@ -2158,7 +2174,10 @@ function hideSeasonalModal() {
document.getElementById('seasonalModal').classList.remove('show');
}
document.getElementById('seasonalForm').addEventListener('submit', async function(e) {
document.addEventListener('DOMContentLoaded', function() {
const seasonalForm = document.getElementById('seasonalForm');
if (!seasonalForm) return;
seasonalForm.addEventListener('submit', async function(e) {
e.preventDefault();
const id = document.getElementById('editSeasonalId').value;
const data = {
@@ -2179,6 +2198,7 @@ document.getElementById('seasonalForm').addEventListener('submit', async functio
loadSeasonalPrices();
} catch(err) { showToast(err.message, 'error'); }
});
});
function editSeasonal(id) { showSeasonalModal(id); }
@@ -2190,6 +2210,176 @@ async function deleteSeasonal(id) {
} catch(err) { showToast(err.message, 'error'); }
}
async function loadActivitiesAdmin() {
try {
const activities = await api('/api/admin/activities');
renderActivitiesCards(activities);
} catch(err) { showToast(err.message, 'error'); }
}
function renderActivitiesCards(activities) {
const grid = document.getElementById('activitiesGrid');
if (!activities || activities.length === 0) {
grid.innerHTML = '<div class="text-center text-muted" style="padding: 40px; grid-column: 1 / -1;">Нет развлечений. Нажмите "Добавить" для создания.</div>';
return;
}
grid.innerHTML = activities.map(a => {
const imageSrc = a.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="14" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
let langs = [];
if (a.title_ru || a.desc_ru) langs.push('RU');
if (a.title_en || a.desc_en) langs.push('EN');
if (a.title_ab || a.desc_ab) langs.push('AB');
const langsBadge = langs.length === 3
? '<span class="badge badge-admin" style="margin-left:6px;">RU+EN+AB</span>'
: `<span class="badge badge-user" style="margin-left:6px;">${langs.join('+')}</span>`;
const statusClass = a.is_active ? 'badge-status-оплачена' : 'badge-status-отменена';
const statusText = a.is_active ? 'Активен' : 'Скрыт';
const iconHtml = a.icon ? `<span style="color:#c9a84c;margin-right:6px;"><i class="${a.icon}"></i></span>` : '';
const title = a.title_ru || a.title_en || a.title_ab || 'Без названия';
return '<div class="room-admin-card">' +
'<div class="room-admin-image">' +
'<img src="' + esc(imageSrc) + '" alt="' + esc(title) + '" 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=%2214%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EБез фото%3C/text%3E%3C/svg%3E\'">' +
'<span class="badge ' + statusClass + '" style="position:absolute;top:10px;left:10px;font-size:0.65rem;">' + statusText + '</span>' +
'<span style="position:absolute;top:10px;right:10px;font-size:0.65rem;">#' + a.sort_order + '</span>' +
'</div>' +
'<div class="room-admin-body">' +
'<h4 class="room-admin-name">' + iconHtml + esc(title) + langsBadge + '</h4>' +
'<div class="room-admin-meta">' +
'<span><i class="fas fa-globe"></i> ' + langs.join(' + ') + '</span>' +
'<span><i class="fas fa-sort-numeric-down"></i> Порядок: ' + a.sort_order + '</span>' +
'</div>' +
'<div class="room-admin-actions">' +
'<button class="btn-primary-custom btn-sm" onclick="showActivityModal(' + a.id + ')" style="flex:1;"><i class="fas fa-edit"></i> Редактировать</button>' +
'<button class="btn-' + (a.is_active ? 'warning' : 'success') + ' btn-sm" onclick="toggleActivityActive(' + a.id + ', ' + !a.is_active + ')" style="padding:8px 12px;"><i class="fas fa-' + (a.is_active ? 'eye-slash' : 'eye') + '"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteActivity(' + a.id + ')" style="padding:8px 12px;"><i class="fas fa-trash"></i></button>' +
'</div>' +
'</div>' +
'</div>';
}).join('');
}
async function toggleActivityActive(id, isActive) {
try {
await api('/api/admin/activities/' + id, { method: 'PUT', body: JSON.stringify({ is_active: isActive ? 1 : 0 }) });
loadActivitiesAdmin();
} catch(err) { showToast(err.message, 'error'); }
}
async function showActivityModal(id) {
document.getElementById('activityModalTitle').textContent = id ? 'Редактировать развлечение' : 'Добавить развлечение';
document.getElementById('editActivityId').value = id || '';
document.getElementById('actTitleRu').value = '';
document.getElementById('actTitleEn').value = '';
document.getElementById('actTitleAb').value = '';
document.getElementById('actDescRu').value = '';
document.getElementById('actDescEn').value = '';
document.getElementById('actDescAb').value = '';
document.getElementById('actIcon').value = '';
document.getElementById('actSortOrder').value = '0';
document.getElementById('actIsActive').checked = true;
document.getElementById('activityImageInput').value = '';
document.getElementById('activityImagePreview').innerHTML = '';
tempActivityImagePath = null;
editingActivityId = id || null;
if (id) {
try {
const activities = await api('/api/admin/activities');
const a = activities.find(x => x.id === id);
if (a) {
document.getElementById('actTitleRu').value = a.title_ru || '';
document.getElementById('actTitleEn').value = a.title_en || '';
document.getElementById('actTitleAb').value = a.title_ab || '';
document.getElementById('actDescRu').value = a.desc_ru || '';
document.getElementById('actDescEn').value = a.desc_en || '';
document.getElementById('actDescAb').value = a.desc_ab || '';
document.getElementById('actIcon').value = a.icon || '';
document.getElementById('actSortOrder').value = a.sort_order || 0;
document.getElementById('actIsActive').checked = a.is_active === 1;
tempActivityImagePath = a.image_path || null;
if (a.image_path) {
document.getElementById('activityImagePreview').innerHTML = `<img src="${a.image_path}" style="max-width:200px;max-height:120px;object-fit:cover;border-radius:8px;">`;
}
}
} catch(e) { showToast(e.message, 'error'); }
}
document.getElementById('activityModal').classList.add('show');
}
function closeActivityModal() {
document.getElementById('activityModal').classList.remove('show');
editingActivityId = null;
tempActivityImagePath = null;
}
async function saveActivity() {
const data = {
title_ru: document.getElementById('actTitleRu').value.trim() || null,
title_en: document.getElementById('actTitleEn').value.trim() || null,
title_ab: document.getElementById('actTitleAb').value.trim() || null,
desc_ru: document.getElementById('actDescRu').value.trim() || null,
desc_en: document.getElementById('actDescEn').value.trim() || null,
desc_ab: document.getElementById('actDescAb').value.trim() || null,
icon: document.getElementById('actIcon').value.trim() || null,
sort_order: parseInt(document.getElementById('actSortOrder').value) || 0,
is_active: document.getElementById('actIsActive').checked ? 1 : 0
};
const filled = [!!data.title_ru || !!data.desc_ru, !!data.title_en || !!data.desc_en, !!data.title_ab || !!data.desc_ab].filter(Boolean).length;
if (filled !== 0 && filled !== 1 && filled !== 3) {
showToast('Должен быть заполнен 1 язык или все 3', 'error');
return;
}
if (tempActivityImagePath) {
data.image_path = tempActivityImagePath;
}
try {
if (editingActivityId) {
await api('/api/admin/activities/' + editingActivityId, { method: 'PUT', body: JSON.stringify(data) });
showToast('Развлечение обновлено');
} else {
await api('/api/admin/activities', { method: 'POST', body: JSON.stringify(data) });
showToast('Развлечение добавлено');
}
closeActivityModal();
loadActivitiesAdmin();
} catch(err) { showToast(err.message, 'error'); }
}
async function deleteActivity(id) {
if (!confirm('Удалить это развлечение?')) return;
try {
await api('/api/admin/activities/' + id, { method: 'DELETE' });
showToast('Развлечение удалено');
loadActivitiesAdmin();
} catch(err) { showToast(err.message, 'error'); }
}
async function uploadActivityImage() {
const input = document.getElementById('activityImageInput');
const file = input.files[0];
if (!file) { showToast('Выберите файл', 'error'); return; }
const fd = new FormData();
fd.append('image', file);
try {
const res = await fetch(API + '/api/admin/activities/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: fd
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
tempActivityImagePath = data.path;
document.getElementById('activityImagePreview').innerHTML = `<img src="${data.path}" style="max-width:200px;max-height:120px;object-fit:cover;border-radius:8px;">`;
showToast('Изображение загружено');
} catch(err) { showToast(err.message, 'error'); }
}
// Security keys
async function loadSecurityKeys() {
try {
@@ -2510,5 +2700,78 @@ async function showRoomModal(id) {
</div>
</div>
<div class="modal-backdrop-custom" id="activityModal">
<div class="modal-custom" style="max-width: min(95vw, 700px);">
<div class="modal-header-custom">
<h3 id="activityModalTitle">Добавить развлечение</h3>
<button class="modal-close" onclick="closeActivityModal()">&times;</button>
</div>
<form id="activityForm">
<input type="hidden" id="editActivityId">
<div class="modal-body-custom" style="max-height: 65vh; overflow-y: auto;">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Название (RU)</label>
<input type="text" class="form-control" id="actTitleRu" placeholder="На русском">
</div>
<div class="col-md-4">
<label class="form-label">Название (EN)</label>
<input type="text" class="form-control" id="actTitleEn" placeholder="На английском">
</div>
<div class="col-md-4">
<label class="form-label">Название (AB)</label>
<input type="text" class="form-control" id="actTitleAb" placeholder="На абхазском">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Описание (RU)</label>
<textarea class="form-control" id="actDescRu" rows="2" placeholder="На русском"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Описание (EN)</label>
<textarea class="form-control" id="actDescEn" rows="2" placeholder="На английском"></textarea>
</div>
<div class="col-md-4">
<label class="form-label">Описание (AB)</label>
<textarea class="form-control" id="actDescAb" rows="2" placeholder="На абхазском"></textarea>
</div>
</div>
<small class="text-muted d-block mb-3" style="margin-top: -8px;">Заполните либо 1 язык, либо все 3. Промежуточные варианты не допускаются.</small>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Иконка (Font Awesome)</label>
<input type="text" class="form-control" id="actIcon" placeholder="fas fa-water">
<small class="text-muted">Например: fas fa-water, fas fa-coffee, fas fa-utensils</small>
</div>
<div class="col-md-3">
<label class="form-label">Порядок</label>
<input type="number" class="form-control" id="actSortOrder" min="0" value="0">
</div>
<div class="col-md-3 d-flex align-items-end">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="actIsActive" checked>
<label class="form-check-label" for="actIsActive">Активно</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Изображение</label>
<div id="activityImagePreview" style="margin-bottom: 8px;"></div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="file" class="form-control" id="activityImageInput" accept="image/*" style="font-size: 0.85rem; max-width: 300px;">
<button type="button" class="btn btn-primary btn-sm" onclick="uploadActivityImage()"><i class="fas fa-upload"></i> Загрузить</button>
</div>
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG/PNG/WebP, до 5 MB</small>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="closeActivityModal()">Отмена</button>
<button type="button" class="btn-gold btn-sm" onclick="saveActivity()"><i class="fas fa-save"></i> Сохранить</button>
</div>
</form>
</div>
</div>
</body>
</html>

View File

@@ -229,38 +229,7 @@
<div class="abkhazian-pattern"></div>
<p class="section-subtitle" data-i18n="activities.subtitle">SUP-борды, пляжное кафе и незабываемые виды — всё это ждёт вас</p>
</div>
<div class="row g-4">
<div class="col-lg-4 animate-on-scroll">
<div class="activity-card">
<img src="img/105d059e5-a204-499f-a01c-c540f6ae1e5d.webp" alt="SUP-борд">
<div class="activity-overlay">
<div class="activity-icon"><i class="fas fa-water"></i></div>
<h3 class="activity-title" data-i18n="activities.sup_title">SUP-борды</h3>
<p class="activity-desc" data-i18n="activities.sup_desc">Аренда SUP-бордов прямо на пляже. Исследуйте побережье с воды — идеальный способ увидеть Абхазию с нового ракурса!</p>
</div>
</div>
</div>
<div class="col-lg-4 animate-on-scroll">
<div class="activity-card">
<img src="img/18abbb136-0e24-4c92-a568-fec8011e029b.webp" alt="Кафе у пляжа">
<div class="activity-overlay">
<div class="activity-icon"><i class="fas fa-coffee"></i></div>
<h3 class="activity-title" data-i18n="activities.cafe_title">Кафе у пляжа</h3>
<p class="activity-desc" data-i18n="activities.cafe_desc">Всего 2 минуты пешком — уютное кафе с видом на море. Абхазская и русская кухня, свежие морепродукты и ароматный кофе.</p>
</div>
</div>
</div>
<div class="col-lg-4 animate-on-scroll">
<div class="activity-card">
<img src="img/1f00325ba-df2c-4bb9-9ec6-28958e11f843.webp" alt="Столовая">
<div class="activity-overlay">
<div class="activity-icon"><i class="fas fa-utensils"></i></div>
<h3 class="activity-title" data-i18n="activities.canteen_title">Столовая Hotel 777</h3>
<p class="activity-desc" data-i18n="activities.canteen_desc">Домашняя кухня каждый день! Завтраки, обеды и ужины из свежих местных продуктов. Аджика, хачапури, азу и многое другое.</p>
</div>
</div>
</div>
</div>
<div class="row g-4" id="activitiesContainer"></div>
</div>
</section>

View File

@@ -16,6 +16,7 @@ const I18n = {
localStorage.setItem('lang', lang);
this.updateUI();
this.updateNavLabels();
if (typeof loadActivities === 'function') loadActivities();
} catch (err) {
console.error('I18n error:', err);
if (lang !== 'ru') {

View File

@@ -379,3 +379,49 @@ document.addEventListener('keydown', function(e) {
closeLightbox();
}
});
async function loadActivities() {
const container = document.getElementById('activitiesContainer');
if (!container) return;
const lang = (window.I18n && I18n.currentLang) ? I18n.currentLang : 'ru';
try {
const res = await fetch('/api/activities?lang=' + lang);
if (!res.ok) throw new Error('Failed to load activities');
const activities = await res.json();
if (!activities.length) {
container.innerHTML = '';
return;
}
container.innerHTML = activities.map(a => {
const imgSrc = a.image_path || '';
const iconHtml = a.icon ? `<div class="activity-icon"><i class="${a.icon}"></i></div>` : '';
const onclickAttr = imgSrc ? `onclick="openLightbox('${imgSrc}', '${(a.title || '').replace(/'/g, "\\'")}')"` : '';
return `<div class="col-lg-4 animate-on-scroll">
<div class="activity-card" ${onclickAttr}>
${imgSrc ? `<img src="${imgSrc}" alt="${a.title || ''}">` : ''}
<div class="activity-overlay">
${iconHtml}
<h3 class="activity-title">${a.title || ''}</h3>
<p class="activity-desc">${a.desc || ''}</p>
</div>
</div>
</div>`;
}).join('');
container.querySelectorAll('.animate-on-scroll').forEach(el => {
observer.observe(el);
});
} catch (err) {
console.error('Activities load error:', err);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadActivities);
} else {
loadActivities();
}

View File

@@ -62,13 +62,15 @@ function renderRoomsPublic(rooms) {
}).join('');
imagesHtml = `<div class="room-img-carousel">
<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img" id="roomCarouselImg${index}"
onclick="roomImageClick(${index})"
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'">
<button class="room-carousel-prev" onclick="roomCarouselPrev(${index})"></button>
<button class="room-carousel-next" onclick="roomCarouselNext(${index})"></button>
<div class="room-img-dots">${dotsHtml}</div>
</div>`;
} else {
imagesHtml = `<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img"
imagesHtml = `<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img" id="roomCarouselImg${index}"
onclick="roomImageClick(${index})"
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'">`;
}
@@ -120,11 +122,28 @@ function renderRoomsPublic(rooms) {
}).join('');
initRoomBookingHandlers();
Object.values(roomCarouselTimers).forEach(t => clearInterval(t));
roomCarouselTimers = {};
rooms.forEach((room, i) => {
const images = Array.isArray(room.images) ? room.images : [];
if (images.length > 1) {
roomCarouselTimers[i] = setInterval(() => roomCarouselNext(i), 10000);
}
});
}
let roomCarouselState = {};
let roomCarouselTimers = {};
function roomImageClick(index) {
const img = document.getElementById('roomCarouselImg' + index);
const room = cachedRooms[index];
if (img && room) {
openLightbox(img.src, room.name);
}
}
function roomCarouselPrev(index) {
if (!roomCarouselState[index]) roomCarouselState[index] = 0;
const imgs = getRoomImagesForIndex(index);

View File

@@ -26,6 +26,8 @@ const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123';
const roomsUploadsDir = path.join(__dirname, 'data', 'room_images');
if (!fs.existsSync(roomsUploadsDir)) fs.mkdirSync(roomsUploadsDir, { recursive: true });
const activityUploadsDir = path.join(__dirname, 'data', 'activity_images');
if (!fs.existsSync(activityUploadsDir)) fs.mkdirSync(activityUploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, roomsUploadsDir),
@@ -47,6 +49,26 @@ const upload = multer({
}
});
const activityStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, activityUploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const timestamp = Date.now();
cb(null, `${timestamp}${ext}`);
}
});
const uploadActivity = multer({
storage: activityStorage,
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);
@@ -198,6 +220,7 @@ const backupModule = require('./modules/backup');
const seasonalPricesModule = require('./modules/seasonalPrices');
const emailModule = require('./modules/email');
const reportsModule = require('./modules/reports');
const activitiesModule = require('./modules/activities');
const { runStartupTests } = require('./tests/runStartupTests');
modules.auth = authModule;
@@ -213,6 +236,7 @@ modules.backup = backupModule;
modules.seasonalPrices = seasonalPricesModule;
modules.email = emailModule;
modules.reports = reportsModule;
modules.activities = activitiesModule;
authModule.init(db, JWT_SECRET);
bookingsModule.init(db);
@@ -226,6 +250,7 @@ backupModule.init(db, dbPath);
seasonalPricesModule.init(db);
emailModule.init(db, settingsModule);
reportsModule.init(db);
activitiesModule.init(db);
function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
@@ -297,6 +322,7 @@ backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAd
seasonalPricesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
emailModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity);
app.get('/api/translations/:lang', (req, res) => {
const lang = req.params.lang;
@@ -379,6 +405,15 @@ app.get('/data/room_images/:filename', (req, res) => {
}
});
app.get('/data/activity_images/:filename', (req, res) => {
const filePath = path.join(activityUploadsDir, 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'));
});