Отзывы

This commit is contained in:
2026-05-10 21:42:31 +05:00
parent 001587799c
commit b8d7f23ef5
18 changed files with 2632 additions and 102 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
node_modules
package-lock.json
.env
data
/data/
promt

15
check_db.js Normal file
View File

@@ -0,0 +1,15 @@
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('hotel.db');
db.all("SELECT name FROM sqlite_master WHERE type='table'", [], (err, tables) => {
console.log('Tables:', tables.map(t => t.name).join(', '));
db.all("PRAGMA table_info(reviews)", [], (err, cols) => {
console.log('reviews columns:', cols.map(c => c.name).join(', '));
db.all("SELECT COUNT(*) as cnt FROM reviews", [], (err, rows) => {
console.log('Reviews count:', rows && rows[0] ? rows[0].cnt : 0);
db.close();
});
});
});

8
check_settings.js Normal file
View File

@@ -0,0 +1,8 @@
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('data/bookings.db');
db.all("SELECT * FROM settings", [], (err, rows) => {
console.log('Settings:', rows);
console.log('Error:', err);
db.close();
});

0
hotel.db Normal file
View File

276
modules/reviews/index.js Normal file
View File

@@ -0,0 +1,276 @@
let db;
let settingsModule;
function init(database, settings) {
db = database;
settingsModule = settings;
}
function getApprovedReviews(req, res) {
const lang = req.query.lang || 'ru';
db.all(
`SELECT id, author_name, country, city, stars, text, created_at
FROM reviews
WHERE is_approved = 1
ORDER BY created_at DESC`,
[],
(err, rows) => {
if (err) {
console.error('Get reviews 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;
});
const stats = rows.length > 0 ? {
count: rows.length,
avgStars: rows.reduce((sum, r) => sum + r.stars, 0) / rows.length
} : { count: 0, avgStars: 0 };
res.json({ reviews: rows, stats: stats });
}
);
}
function createReview(req, res) {
const { author_name, country_code, country_name, city, stars, text, review_code } = req.body;
const ip = req.ip || req.connection.remoteAddress || 'unknown';
if (!author_name || !country_code || stars === undefined || !text || !review_code) {
return res.status(400).json({ error: 'Missing required fields' });
}
const country = country_name || country_code;
if (author_name.length < 2) {
return res.status(400).json({ error: 'Name must be at least 2 characters' });
}
if (text.length < 20) {
return res.status(400).json({ error: 'Review text must be at least 20 characters' });
}
if (stars < 0 || stars > 5) {
return res.status(400).json({ error: 'Stars must be between 0 and 5' });
}
settingsModule.getReviewCode((err, correctCode) => {
if (err) return res.status(500).json({ error: 'Server error' });
if (review_code !== correctCode) {
return res.status(400).json({ error: 'Invalid review code' });
}
settingsModule.checkIpCooldown(ip, (err, isCooldown) => {
if (err) return res.status(500).json({ error: 'Server error' });
if (isCooldown) {
return res.status(429).json({ error: 'You have recently submitted a review. Please try again later.' });
}
const stmt = db.prepare(
`INSERT INTO reviews (author_name, country, country_code, city, stars, text, review_code, ip_address, is_approved)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)`
);
stmt.run(author_name, country, country_code, city, parseFloat(stars).toFixed(1), text, review_code, ip, function(err) {
if (err) {
console.error('Create review error:', err);
return res.status(500).json({ error: 'Database error' });
}
res.status(201).json({
message: 'Review submitted for moderation',
id: this.lastID
});
});
stmt.finalize();
});
});
}
function getAllReviews(req, res) {
const filter = req.query.filter || 'all';
let whereClause = '1=1';
if (filter === 'pending') {
whereClause = 'is_approved = 0';
} else if (filter === 'approved') {
whereClause = 'is_approved = 1';
} else if (filter === 'rejected') {
whereClause = 'is_approved = -1';
}
db.all(
`SELECT * FROM reviews WHERE ${whereClause} ORDER BY created_at DESC`,
[],
(err, rows) => {
if (err) {
console.error('Get all reviews error:', err);
return res.status(500).json({ error: 'Database error' });
}
const stats = {
total: rows.length,
pending: rows.filter(r => r.is_approved === 0).length,
approved: rows.filter(r => r.is_approved === 1).length,
rejected: rows.filter(r => r.is_approved === -1).length
};
db.get(`SELECT COUNT(*) as cnt, SUM(CASE WHEN is_approved = 0 THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN is_approved = 1 THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN is_approved = -1 THEN 1 ELSE 0 END) as rejected
FROM reviews`, [], (err, globalStats) => {
if (!err && globalStats) {
stats.total = globalStats.cnt || 0;
stats.pending = globalStats.pending || 0;
stats.approved = globalStats.approved || 0;
stats.rejected = globalStats.rejected || 0;
}
rows.forEach(row => {
row.created_at = row.created_at ? new Date(row.created_at).toISOString() : null;
});
res.json({ reviews: rows, stats: stats });
});
}
);
}
function approveReview(req, res) {
const reviewId = parseInt(req.params.id);
const { approved } = req.body;
const newStatus = approved ? 1 : -1;
db.run(
`UPDATE reviews SET is_approved = ? WHERE id = ?`,
[newStatus, reviewId],
function(err) {
if (err) {
console.error('Approve review error:', err);
return res.status(500).json({ error: 'Database error' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Review not found' });
}
res.json({ message: 'Review status updated', id: reviewId, is_approved: newStatus });
}
);
}
function deleteReview(req, res) {
const reviewId = parseInt(req.params.id);
db.run(`DELETE FROM reviews WHERE id = ?`, [reviewId], function(err) {
if (err) {
console.error('Delete review error:', err);
return res.status(500).json({ error: 'Database error' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Review not found' });
}
res.json({ message: 'Review deleted', id: reviewId });
});
}
function getPopularCountries(req, res) {
if (!db) {
return res.status(500).json({ error: 'Database not initialized' });
}
db.all(`
SELECT country_code, COUNT(*) as count
FROM reviews
WHERE is_approved = 1 AND country_code IS NOT NULL AND country_code != ''
GROUP BY country_code
ORDER BY count DESC
LIMIT 20
`, (err, countries) => {
if (err) {
console.error('Get popular countries error:', err);
return res.status(500).json({ error: err.message });
}
if (!countries || countries.length === 0) {
return res.json({ countries: [], cities: {} });
}
const countryCodes = countries.map(c => `'${c.country_code}'`).join(',');
db.all(`
SELECT country_code, city, COUNT(*) as count
FROM reviews
WHERE is_approved = 1 AND country_code IN (${countryCodes})
GROUP BY country_code, city
ORDER BY count DESC
`, (err, cities) => {
if (err) {
console.error('Get popular cities error:', err);
return res.status(500).json({ error: err.message });
}
const citiesByCountry = {};
if (cities) {
cities.forEach(c => {
if (!citiesByCountry[c.country_code]) {
citiesByCountry[c.country_code] = [];
}
citiesByCountry[c.country_code].push(c.city);
});
}
res.json({ countries, cities: citiesByCountry });
});
});
}
function getPopularCitiesByCountry(req, res) {
const countryCode = req.params.countryCode;
if (!db) {
return res.status(500).json({ error: 'Database not initialized' });
}
if (!countryCode) {
return res.json({ popular: [], countryCode: null });
}
db.all(`
SELECT city, COUNT(*) as count
FROM reviews
WHERE is_approved = 1 AND country_code = ?
GROUP BY city
ORDER BY count DESC
`, [countryCode], (err, popularCities) => {
if (err) {
console.error('Get popular cities by country error:', err);
popularCities = [];
}
const popularCityNames = popularCities ? popularCities.map(c => c.city) : [];
res.json({ popular: popularCityNames, countryCode });
});
}
function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/reviews', getApprovedReviews);
app.post('/api/reviews', createReview);
app.get('/api/reviews/popular', getPopularCountries);
app.get('/api/reviews/cities/:countryCode', getPopularCitiesByCountry);
app.get('/api/admin/reviews', authenticateToken, getAllReviews);
app.patch('/api/admin/reviews/:id/approve', authenticateToken, approveReview);
app.delete('/api/admin/reviews/:id', authenticateToken, deleteReview);
}
module.exports = { init, setupRoutes };

114
modules/settings/index.js Normal file
View File

@@ -0,0 +1,114 @@
let db;
const DEFAULT_SETTINGS = {
review_code: 'GUEST2026',
review_min_length: '20',
review_ip_cooldown_minutes: '5'
};
function init(database) {
db = database;
initDefaultSettings();
}
function initDefaultSettings() {
Object.entries(DEFAULT_SETTINGS).forEach(([key, value]) => {
db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, [key, value], (err) => {
if (err) console.error('Settings init error:', err);
else console.log(`✅ Setting "${key}" initialized/confirmed`);
});
});
}
function get(key, callback) {
db.get(`SELECT value FROM settings WHERE key = ?`, [key], (err, row) => {
if (err) {
console.error('Settings get error:', err);
return callback(err, null);
}
callback(null, row ? row.value : null);
});
}
function set(key, value, callback) {
db.run(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP`,
[key, value, value],
function(err) {
if (err) {
console.error('Settings set error:', err);
return callback(err);
}
callback(null);
}
);
}
function getAll(callback) {
db.all(`SELECT * FROM settings`, [], (err, rows) => {
if (err) {
console.error('Settings getAll error:', err);
return callback(err, null);
}
const settings = {};
rows.forEach(row => { settings[row.key] = row.value; });
callback(null, settings);
});
}
function getReviewCode(callback) {
get('review_code', callback);
}
function setReviewCode(value, callback) {
set('review_code', value, callback);
}
function checkIpCooldown(ip, callback) {
const cooldownMinutes = 5;
const cutoffTime = new Date(Date.now() - cooldownMinutes * 60 * 1000).toISOString();
db.get(
`SELECT id FROM reviews WHERE ip_address = ? AND created_at > ? LIMIT 1`,
[ip, cutoffTime],
(err, row) => {
if (err) {
console.error('IP cooldown check error:', err);
return callback(err, false);
}
callback(null, !!row);
}
);
}
function setupRoutes(app, authenticateToken, requireAdmin) {
app.get('/api/admin/settings', authenticateToken, requireAdmin, (req, res) => {
getAll((err, settings) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (settings.review_code) {
settings.review_code_masked = settings.review_code.replace(/./g, '*');
}
res.json(settings);
});
});
app.get('/api/admin/settings/review-code', authenticateToken, requireAdmin, (req, res) => {
getReviewCode((err, code) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ code: code });
});
});
app.put('/api/admin/settings/review-code', authenticateToken, requireAdmin, (req, res) => {
const { code } = req.body;
if (!code || code.length < 3) {
return res.status(400).json({ error: 'Code must be at least 3 characters' });
}
setReviewCode(code, (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Review code updated', code: code });
});
});
}
module.exports = { init, get, set, getAll, getReviewCode, setReviewCode, checkIpCooldown, setupRoutes };

View File

@@ -0,0 +1,217 @@
const translations = {
ru: {
// Navigation & General
'nav.about': 'О нас',
'nav.rooms': 'Номера',
'nav.gallery': 'Галерея',
'nav.activities': 'Развлечения',
'nav.reviews': 'Отзывы',
'nav.host': 'Хозяин',
'nav.contact': 'Контакты',
'nav.admin': 'Управление',
// Reviews Section
'reviews.title': 'Отзывы наших гостей',
'reviews.subtitle': 'Более {count} счастливых гостей рекомендуют Hotel 777',
'reviews.leave_review': 'Оставить отзыв',
'reviews.write_review': 'Написать отзыв',
'reviews.avg_rating': 'Средняя оценка',
'reviews.stars': 'звёзд',
'reviews.star': 'звезда',
'reviews.write_first': 'Будьте первым, кто оставит отзыв!',
// Select Groups
'select.popular': 'Популярные',
'select.alphabetical': 'По алфавиту',
// Review Form
'form.select_country': 'Выберите страну',
'form.select_city': 'Город (необязательно)',
'form.another_city': 'Другой город',
'form.city_placeholder': 'Введите название города',
'form.rating': 'Оценка',
'form.your_name': 'Ваше имя (ФИО)',
'form.name_placeholder': 'Иванов Иван Иванович',
'form.review_text': 'Текст отзыва',
'form.review_placeholder': 'Расскажите о вашем опыте отдыха в Hotel 777...',
'form.hotel_code': 'Код гостиницы',
'form.code_placeholder': 'Получите на ресепшене',
'form.code_hint': 'Код сообщается гостям на ресепшене отеля',
'form.submit': 'Отправить отзыв',
'form.cancel': 'Отмена',
// Validation Messages
'validation.required': 'Это поле обязательно',
'validation.country_required': 'Выберите страну',
'validation.city_required': 'Выберите или введите город',
'validation.name_required': 'Введите ваше имя',
'validation.name_min': 'Имя должно содержать минимум 2 символа',
'validation.review_required': 'Напишите текст отзыва',
'validation.review_min': 'Отзыв должен содержать минимум 20 символов',
'validation.code_required': 'Введите код гостиницы',
'validation.code_invalid': 'Неверный код гостиницы',
'validation.rating_required': 'Поставьте оценку',
'validation.rating_range': 'Оценка должна быть от 0 до 5',
'validation.too_frequent': 'Вы уже оставляли отзыв недавно. Попробуйте позже.',
'validation.success': 'Спасибо! Ваш отзыв отправлен на модерацию и будет опубликован после проверки.',
// Admin
'admin.reviews': 'Отзывы',
'admin.reviews_all': 'Все отзывы',
'admin.reviews_pending': 'На модерации',
'admin.reviews_approved': 'Одобренные',
'admin.reviews_rejected': 'Скрытые',
'admin.approve': 'Одобрить',
'admin.reject': 'Скрыть',
'admin.delete': 'Удалить',
'admin.approve_confirm': 'Одобрить этот отзыв?',
'admin.reject_confirm': 'Скрыть этот отзыв?',
'admin.delete_confirm': 'Удалить этот отзыв?',
'admin.stats.total': 'Всего отзывов',
'admin.stats.pending': 'Ожидают модерации',
'admin.stats.approved': 'Опубликовано',
'admin.settings': 'Настройки',
'admin.settings.review_code': 'Кодовое слово для отзывов',
'admin.settings.current_code': 'Текущий код',
'admin.settings.new_code': 'Новый код',
'admin.settings.save': 'Сохранить',
'admin.settings.saved': 'Кодовое слово обновлено',
// Reviews List
'review.from': 'из',
'review.date_format': 'MMMM YYYY',
'review.no_reviews': 'Отзывов пока нет',
// Footer
'footer.rights': 'Все права защищены',
'footer.hotel777': 'Hotel 777',
// Buttons
'btn.close': 'Закрыть',
'btn.loading': 'Отправка...',
'btn.show_code': 'Показать',
'btn.hide_code': 'Скрыть',
// Language
'lang.switch': 'English',
'lang.current': 'Русский'
},
en: {
// Navigation & General
'nav.about': 'About Us',
'nav.rooms': 'Rooms',
'nav.gallery': 'Gallery',
'nav.activities': 'Activities',
'nav.reviews': 'Reviews',
'nav.host': 'Host',
'nav.contact': 'Contact',
'nav.admin': 'Management',
// Reviews Section
'reviews.title': 'Guest Reviews',
'reviews.subtitle': 'More than {count} happy guests recommend Hotel 777',
'reviews.leave_review': 'Leave a Review',
'reviews.write_review': 'Write a Review',
'reviews.avg_rating': 'Average rating',
'reviews.stars': 'stars',
'reviews.star': 'star',
'reviews.write_first': 'Be the first to leave a review!',
// Select Groups
'select.popular': 'Popular',
'select.alphabetical': 'Alphabetical',
// Review Form
'form.select_country': 'Select country',
'form.select_city': 'City (optional)',
'form.another_city': 'Other city',
'form.city_placeholder': 'Enter city name',
'form.rating': 'Rating',
'form.your_name': 'Your name',
'form.name_placeholder': 'John Smith',
'form.review_text': 'Review text',
'form.review_placeholder': 'Tell us about your experience at Hotel 777...',
'form.hotel_code': 'Hotel code',
'form.code_placeholder': 'Get at reception',
'form.code_hint': 'The code is provided to guests at the hotel reception',
'form.submit': 'Submit Review',
'form.cancel': 'Cancel',
// Validation Messages
'validation.required': 'This field is required',
'validation.country_required': 'Please select a country',
'validation.city_required': 'Please select or enter a city',
'validation.name_required': 'Please enter your name',
'validation.name_min': 'Name must be at least 2 characters',
'validation.review_required': 'Please write your review',
'validation.review_min': 'Review must be at least 20 characters',
'validation.code_required': 'Please enter hotel code',
'validation.code_invalid': 'Invalid hotel code',
'validation.rating_required': 'Please provide a rating',
'validation.rating_range': 'Rating must be between 0 and 5',
'validation.too_frequent': 'You have recently left a review. Please try again later.',
'validation.success': 'Thank you! Your review has been submitted for moderation and will be published after approval.',
// Admin
'admin.reviews': 'Reviews',
'admin.reviews_all': 'All Reviews',
'admin.reviews_pending': 'Pending',
'admin.reviews_approved': 'Approved',
'admin.reviews_rejected': 'Hidden',
'admin.approve': 'Approve',
'admin.reject': 'Hide',
'admin.delete': 'Delete',
'admin.approve_confirm': 'Approve this review?',
'admin.reject_confirm': 'Hide this review?',
'admin.delete_confirm': 'Delete this review?',
'admin.stats.total': 'Total reviews',
'admin.stats.pending': 'Awaiting moderation',
'admin.stats.approved': 'Published',
'admin.settings': 'Settings',
'admin.settings.review_code': 'Review access code',
'admin.settings.current_code': 'Current code',
'admin.settings.new_code': 'New code',
'admin.settings.save': 'Save',
'admin.settings.saved': 'Code word updated',
// Reviews List
'review.from': 'from',
'review.date_format': 'MMMM YYYY',
'review.no_reviews': 'No reviews yet',
// Footer
'footer.rights': 'All rights reserved',
'footer.hotel777': 'Hotel 777',
// Buttons
'btn.close': 'Close',
'btn.loading': 'Sending...',
'btn.show_code': 'Show',
'btn.hide_code': 'Hide',
// Language
'lang.switch': 'Русский',
'lang.current': 'English'
}
};
function t(key, lang = 'ru', replacements = {}) {
let text = translations[lang]?.[key] || translations['ru'][key] || key;
Object.entries(replacements).forEach(([k, v]) => {
text = text.replace(`{${k}}`, v);
});
return text;
}
function getTranslations(lang) {
return translations[lang] || translations['ru'];
}
function getAvailableLanguages() {
return Object.keys(translations);
}
module.exports = { t, getTranslations, getAvailableLanguages, translations };

View File

@@ -67,6 +67,22 @@ body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
.badge-status-выехала { background: #e2e8f0; color: #475569; }
.badge-status-отменена { background: #fee2e2; color: #b91c1c; }
.review-text-cell { max-width: 300px; }
.review-text-full {
max-height: 150px;
overflow-y: auto;
padding: 8px;
background: #f8fafc;
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.review-text-full::-webkit-scrollbar { width: 6px; }
.review-text-full::-webkit-scrollbar-track { background: #e2e8f0; border-radius: 3px; }
.review-text-full::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
tr.row-checkin-soon { background: #fffbeb !important; border-left: 4px solid #f59e0b; }
tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #ef4444; }
@@ -162,6 +178,8 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<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="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>
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
<a href="/" style="margin-top: 8px; color: #94a3b8; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;"><i class="fas fa-arrow-left"></i> На сайт</a>
</nav>
@@ -200,6 +218,11 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<div class="stat-value" id="statNew"></div>
<div class="stat-label">Новых заявок</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: #fef3c7; color: #c9a84c;"><i class="fas fa-star"></i></div>
<div class="stat-value" id="statPendingReviews"></div>
<div class="stat-label">Отзывов на модерации</div>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Последние бронирования</h3></div>
@@ -277,6 +300,62 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
<div id="tab-reviews" class="tab-content">
<div class="top-bar">
<h1>Отзывы</h1>
<div class="filter-bar">
<select id="filterReviews" onchange="loadReviews()">
<option value="all">Все</option>
<option value="pending">На модерации</option>
<option value="approved">Одобренные</option>
<option value="rejected">Скрытые</option>
</select>
</div>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Автор</th><th>Местоположение</th><th>Оценка</th><th>Текст</th><th>Статус</th><th>Дата</th><th>Действия</th></tr></thead>
<tbody id="reviewsTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-settings" class="tab-content">
<div class="top-bar">
<h1>Настройки</h1>
</div>
<div class="card">
<div class="card-header-custom"><h3>Кодовое слово для отзывов</h3></div>
<div class="card-body-custom">
<p style="color: #64748b; margin-bottom: 20px;">
<i class="fas fa-info-circle me-2"></i>
Это кодовое слово гости должны вводить при оставлении отзыва. Сообщите его гостям на ресепшене.
</p>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Текущий код</label>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control" id="currentCodeDisplay" value="Загрузка..." readonly style="background: #f8fafc; font-weight: 600; color: #0f172a;">
</div>
</div>
<form id="settingsForm">
<div class="mb-3">
<label class="form-label">Новый код</label>
<input type="text" class="form-control" id="newReviewCode" placeholder="Минимум 3 символа" minlength="3">
</div>
<button type="submit" class="btn-gold">
<i class="fas fa-save me-2"></i>Сохранить
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div id="tab-profile" class="tab-content">
<div class="top-bar"><h1>Мой профиль</h1></div>
<div class="card">
@@ -463,6 +542,8 @@ function initTabs() {
if (tab === 'users') loadUsers();
if (tab === 'bookings') { bookingsLoaded = false; loadBookings(); }
if (tab === 'promocodes') loadPromocodes();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
});
});
@@ -546,6 +627,10 @@ async function loadDashboard() {
} catch(e) {
document.getElementById('recentBookings').innerHTML = '<tr><td colspan="5" class="text-center text-muted">Нет данных</td></tr>';
}
try {
const reviewData = await api('/api/admin/reviews');
document.getElementById('statPendingReviews').textContent = reviewData.stats.pending;
} catch(e) {}
}
async function loadUsers() {
@@ -992,6 +1077,112 @@ async function changeDetails(id, field, value) {
}
checkAuth();
// Reviews Tab Functions
async function loadReviews() {
try {
const data = await api('/api/admin/reviews');
renderReviews(data.reviews, data.stats);
document.getElementById('statPendingReviews').textContent = data.stats.pending;
} catch(err) { showToast(err.message, 'error'); }
}
function renderReviews(reviews, stats) {
const tbody = document.getElementById('reviewsTable');
if (reviews.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted">Нет отзывов</td></tr>';
return;
}
tbody.innerHTML = reviews.map(r => {
const stars = renderStarsHTML(r.stars);
const statusClass = r.is_approved === 1 ? 'bg-success' : (r.is_approved === -1 ? 'bg-secondary' : 'bg-warning');
const statusText = r.is_approved === 1 ? 'Одобрен' : (r.is_approved === -1 ? 'Скрыт' : 'На модерации');
const date = r.created_at ? new Date(r.created_at).toLocaleDateString('ru-RU') : '—';
const location = [r.country, r.city].filter(Boolean).join(', ') || '—';
return '<tr>' +
'<td><strong>' + esc(r.author_name) + '</strong></td>' +
'<td>' + location + '</td>' +
'<td><span class="text-warning">' + stars + '</span> ' + r.stars.toFixed(1) + '</td>' +
'<td class="review-text-cell"><div class="review-text-full" id="reviewText' + r.id + '">' + esc(r.text).replace(/\n/g, '<br>') + '</div></td>' +
'<td><span class="badge ' + statusClass + '">' + statusText + '</span></td>' +
'<td>' + date + '</td>' +
'<td style="white-space: nowrap;">' +
(r.is_approved !== 1 ? '<button class="btn btn-success btn-sm me-1" onclick="approveReview(' + r.id + ', true)"><i class="fas fa-check"></i></button>' : '') +
(r.is_approved !== -1 && r.is_approved !== 0 ? '<button class="btn btn-warning btn-sm me-1" onclick="approveReview(' + r.id + ', false)"><i class="fas fa-eye-slash"></i></button>' : '') +
'<button class="btn btn-danger btn-sm" onclick="deleteReview(' + r.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>';
}).join('');
}
function renderStarsHTML(count) {
let html = '';
const fullStars = Math.floor(count);
for (let i = 0; i < 5; i++) {
html += i < fullStars ? '<i class="fas fa-star"></i>' : '<i class="far fa-star"></i>';
}
return html;
}
async function approveReview(id, approve) {
try {
await api('/api/admin/reviews/' + id + '/approve', { method: 'PATCH', body: JSON.stringify({ approved: approve }) });
showToast(approve ? 'Отзыв одобрен' : 'Отзыв скрыт');
loadReviews();
} catch(err) { showToast(err.message, 'error'); }
}
async function deleteReview(id) {
if (!confirm('Удалить этот отзыв?')) return;
try {
await api('/api/admin/reviews/' + id, { method: 'DELETE' });
showToast('Отзыв удалён');
loadReviews();
} catch(err) { showToast(err.message, 'error'); }
}
// 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 = 'Ошибка загрузки';
}
}
document.getElementById('settingsForm').addEventListener('submit', async e => {
e.preventDefault();
const newCode = document.getElementById('newReviewCode').value.trim();
if (!newCode || newCode.length < 3) {
showToast('Код должен содержать минимум 3 символа', 'error');
return;
}
try {
await api('/api/admin/settings/review-code', { method: 'PUT', body: JSON.stringify({ code: newCode }) });
showToast('Кодовое слово обновлено');
document.getElementById('newReviewCode').value = '';
loadSettings();
} catch(err) { showToast(err.message, 'error'); }
});
function toggleCodeShow() {
const display = document.getElementById('currentCodeDisplay');
const isMasked = display.textContent.includes('*');
if (isMasked) {
api('/api/admin/settings/review-code').then(data => {
display.textContent = data.code;
});
} else {
display.textContent = '******';
}
}
</script>
</body>
</html>

575
public/css/reviews.css Normal file
View File

@@ -0,0 +1,575 @@
.reviews-section {
background: var(--dark);
}
.reviews-header {
text-align: center;
margin-bottom: 40px;
}
.reviews-stats {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.reviews-avg {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255,255,255,0.1);
padding: 15px 25px;
border-radius: 12px;
}
.reviews-avg-number {
font-size: 2.5rem;
font-weight: 700;
color: var(--gold);
font-family: 'Playfair Display', serif;
}
.reviews-avg-stars {
display: flex;
gap: 3px;
}
.reviews-avg-stars i {
font-size: 1.2rem;
color: var(--gold);
}
.reviews-avg-count {
font-size: 0.9rem;
color: rgba(255,255,255,0.7);
}
.reviews-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 30px;
}
.review-card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255,255,255,0.1);
transition: all 0.3s ease;
}
.review-card:hover {
transform: translateY(-4px);
background: rgba(255,255,255,0.08);
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
}
.review-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.review-author {
display: flex;
align-items: center;
gap: 12px;
}
.review-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, var(--gold), #d4a853);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 700;
color: #fff;
font-family: 'Playfair Display', serif;
}
.review-author-info {
display: flex;
flex-direction: column;
}
.review-author-name {
font-weight: 600;
font-size: 1rem;
color: #fff;
margin-bottom: 4px;
}
.review-author-location {
font-size: 0.8rem;
color: rgba(255,255,255,0.6);
}
.review-stars {
display: flex;
gap: 2px;
}
.review-stars i {
font-size: 0.9rem;
color: var(--gold);
}
.review-stars i.empty {
color: rgba(255,255,255,0.2);
}
.review-stars .half {
position: relative;
}
.review-text {
color: rgba(255,255,255,0.85);
line-height: 1.7;
font-size: 0.95rem;
margin-bottom: 16px;
}
.review-date {
font-size: 0.8rem;
color: rgba(255,255,255,0.4);
text-align: right;
}
.reviews-empty {
text-align: center;
padding: 60px 20px;
color: rgba(255,255,255,0.6);
}
.reviews-empty i {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.3;
}
.reviews-empty p {
font-size: 1.1rem;
}
.reviews-actions {
text-align: center;
margin-top: 20px;
}
.reviews-load-more {
background: transparent;
border: 2px solid var(--gold);
color: var(--gold);
padding: 12px 30px;
border-radius: 30px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.reviews-load-more:hover {
background: var(--gold);
color: var(--dark);
}
.reviews-section .btn-gold {
padding: 14px 32px;
font-size: 1rem;
}
.review-modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
overflow-y: auto;
}
.review-modal.show {
display: flex;
}
.review-modal-content {
background: #fff;
border-radius: 20px;
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.review-modal-header {
padding: 24px 28px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: #fff;
z-index: 1;
border-radius: 20px 20px 0 0;
}
.review-modal-header h3 {
font-family: 'Playfair Display', serif;
font-size: 1.5rem;
margin: 0;
color: var(--dark);
}
.review-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.review-modal-close:hover {
background: #f5f5f5;
color: #333;
}
.review-modal-body {
padding: 28px;
}
.review-form-group {
margin-bottom: 20px;
}
.review-form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
font-size: 0.9rem;
color: var(--dark);
}
.review-form-group label .required {
color: #ef4444;
}
.review-form-group .hint {
font-weight: 400;
font-size: 0.8rem;
color: #999;
margin-left: 8px;
}
.review-form-control {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 0.95rem;
transition: all 0.2s;
font-family: inherit;
}
.review-form-control:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 4px rgba(201, 168, 76, 0.1);
}
.review-form-control.error {
border-color: #ef4444;
}
.review-select-wrapper {
position: relative;
}
.review-select-wrapper select {
appearance: none;
width: 100%;
padding: 12px 40px 12px 16px;
}
.review-select-wrapper::after {
content: '\f078';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: #999;
font-size: 0.8rem;
}
.review-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 500px) {
.review-row {
grid-template-columns: 1fr;
}
}
.review-stars-input {
display: flex;
flex-direction: column;
gap: 12px;
}
.review-stars-slider-wrapper {
display: flex;
align-items: center;
gap: 16px;
}
.review-stars-display {
display: flex;
gap: 4px;
font-size: 1.5rem;
min-width: 150px;
}
.review-stars-display i {
color: #e5e7eb;
transition: color 0.1s;
}
.review-stars-display i.filled {
color: var(--gold);
}
.review-stars-display i.half {
position: relative;
color: #e5e7eb;
}
.review-stars-display i.half::before {
content: '\f005';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
left: 0;
color: var(--gold);
clip-path: inset(0 50% 0 0);
}
.review-stars-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--gold);
min-width: 60px;
text-align: center;
font-family: 'Playfair Display', serif;
}
.review-stars-slider {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, var(--gold) 0%, var(--gold) var(--value), #e5e7eb var(--value), #e5e7eb 100%);
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.review-stars-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gold);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
border: 3px solid #fff;
}
.review-stars-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gold);
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
border: 3px solid #fff;
}
.review-code-wrapper {
position: relative;
}
.review-code-wrapper .toggle-code {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 4px 8px;
font-size: 0.85rem;
}
.review-code-wrapper .toggle-code:hover {
color: var(--gold);
}
.review-error {
color: #ef4444;
font-size: 0.8rem;
margin-top: 6px;
display: none;
}
.review-error.show {
display: block;
}
.review-modal-footer {
padding: 20px 28px;
border-top: 1px solid #eee;
display: flex;
gap: 12px;
justify-content: flex-end;
position: sticky;
bottom: 0;
background: #fff;
}
.review-btn-cancel {
padding: 12px 24px;
border: 2px solid #e5e7eb;
background: transparent;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.2s;
}
.review-btn-cancel:hover {
border-color: #ccc;
color: #333;
}
.review-btn-submit {
padding: 12px 28px;
background: var(--gold);
border: none;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: #fff;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.review-btn-submit:hover {
background: #b8943f;
}
.review-btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.review-btn-submit .spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.review-success-message {
text-align: center;
padding: 40px 20px;
}
.review-success-message i {
font-size: 4rem;
color: #16a34a;
margin-bottom: 20px;
}
.review-success-message h4 {
font-size: 1.3rem;
margin-bottom: 12px;
color: var(--dark);
}
.review-success-message p {
color: #666;
line-height: 1.6;
}
.language-switcher {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
padding-left: 16px;
border-left: 1px solid rgba(255,255,255,0.2);
}
.lang-btn {
background: transparent;
border: 1px solid rgba(255,255,255,0.3);
color: rgba(255,255,255,0.7);
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
}
.lang-btn:hover,
.lang-btn.active {
background: var(--gold);
border-color: var(--gold);
color: #fff;
}
@media (max-width: 991px) {
.language-switcher {
border-left: none;
padding-left: 0;
margin-left: 0;
margin-top: 12px;
}
}

22
public/data/cities/RU.js Normal file
View File

@@ -0,0 +1,22 @@
const CITIES_RU = [
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", "Казань",
"Краснодар", "Красноярск", "Сочи", "Пермь", "Воронеж", "Волгоград",
"Ростов-на-Дону", "Уфа", "Самара", "Омск", "Челябинск", "Нижний Новгород",
"Минск", "Иркутск", "Хабаровск", "Барнаул", "Владивосток", "Тюмень",
"Ижевск", "Томск", "Кемерово", "Оренбург", "Новокузнецк", "Тольятти",
"Саратов", "Астрахань", "Набережные Челны", "Брянск", "Калининград",
"Липецк", "Курск", "Сочи", "Ставрополь", "Белгород", "Владимир",
"Архангельск", "Севастополь", "Симферополь", "Пенза", "Тула", "Ульяновск",
"Ярославль", "Дзержинск", "Мурманск", "Череповец", "Волжский", "Сургут",
"Смоленск", "Подольск", "Великий Новгород", "Чита", "Калуга", "Благовещенск",
"Вологда", "Курган", "Сыктывкар", "Орёл", "Петрозаводск", "Йошкар-Ола",
"Саранск", "Абакан", "Нальчик", "Элиста", "Черкесск", "Махачкала",
"Грозный", "Ставрополь", "Владикавказ", "Ханты-Мансийск", "Анадырь",
"Петропавловск-Камчатский", "Магадан", "Якутск", "Бийск", "Пятигорск",
"Таганрог", "Новочеркасск", "Азов", "Шахты", "Балашиха", "Королёв",
"Мытищи", "Люберцы", "Красногорск", "Электросталь", "Коломна", "Одинцово",
"Железнодорожный", "Серпухов", "Пушкино", "Щёлково", "Ногинск", "Раменское",
"Домодедово", "Подольск", "Химки", "Братск", "Ангарск", "Норильск",
"Тверь", "Рязань", "Иваново", "Белгород", "Армавир", "Новороссийск",
"Геленджик", "Анапа", "Ейск", "Туапсе", "Кисловодск", "Пятигорск"
];

116
public/data/cities/major.js Normal file
View File

@@ -0,0 +1,116 @@
const CITIES_US = [
"New York", "Los Angeles", "Chicago", "Houston", "Phoenix",
"Philadelphia", "San Antonio", "San Diego", "Dallas", "San Jose",
"Austin", "Jacksonville", "Fort Worth", "Columbus", "Charlotte",
"San Francisco", "Indianapolis", "Seattle", "Denver", "Washington",
"Boston", "El Paso", "Nashville", "Detroit", "Oklahoma City",
"Portland", "Las Vegas", "Memphis", "Louisville", "Baltimore",
"Milwaukee", "Albuquerque", "Tucson", "Fresno", "Sacramento",
"Atlanta", "Miami", "Cleveland", "Omaha", "Minneapolis"
];
const CITIES_GB = [
"London", "Birmingham", "Manchester", "Glasgow", "Liverpool",
"Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff",
"Newcastle", "Nottingham", "Southampton", "Belfast", "Leicester"
];
const CITIES_DE = [
"Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt",
"Stuttgart", "Dusseldorf", "Leipzig", "Dortmund", "Essen",
"Bremen", "Dresden", "Hanover", "Nuremberg", "Duisburg"
];
const CITIES_FR = [
"Paris", "Marseille", "Lyon", "Toulouse", "Nice",
"Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"
];
const CITIES_IT = [
"Rome", "Milan", "Naples", "Turin", "Palermo",
"Genoa", "Florence", "Bologna", "Catania", "Bari"
];
const CITIES_ES = [
"Madrid", "Barcelona", "Valencia", "Seville", "Zaragoza",
"Malaga", "Murcia", "Palma", "Las Palmas", "Bilbao"
];
const CITIES_TR = [
"Istanbul", "Ankara", "Izmir", "Bursa", "Antalya",
"Adana", "Konya", "Gaziantep", "Mersin", "Diyarbakir"
];
const CITIES_KZ = [
"Almaty", "Nur-Sultan", "Shymkent", "Karaganda", "Aktobe",
"Taraz", "Pavlodar", "Ust-Kamenogorsk", "Kyzylorda", "Semey"
];
const CITIES_UA = [
"Kyiv", "Kharkiv", "Odesa", "Dnipro", "Donetsk",
"Lviv", "Zaporizhzhia", "Kryvyi Rih", "Mykolaiv", "Mariupol",
"Vinnytsia", "Kherson", "Poltava", "Chernihiv", "Cherkasy"
];
const CITIES_BY = [
"Minsk", "Gomel", "Mogilev", "Vitebsk", "Grodno",
"Brest", "Baranovichi", "Bobruisk", "Borisov", "Pinsk"
];
const CITIES_CN = [
"Beijing", "Shanghai", "Guangzhou", "Shenzhen", "Chongqing",
"Tianjin", "Wuhan", "Chengdu", "Nanjing", "Xi'an",
"Hangzhou", "Dongguan", "Shenyang", "Qingdao", "Harbin"
];
const CITIES_JP = [
"Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo",
"Fukuoka", "Kobe", "Kyoto", "Kawasaki", "Saitama"
];
const CITIES_IN = [
"Mumbai", "Delhi", "Bangalore", "Hyderabad", "Chennai",
"Kolkata", "Ahmedabad", "Pune", "Surat", "Jaipur"
];
const CITIES_BR = [
"Sao Paulo", "Rio de Janeiro", "Brasilia", "Salvador", "Fortaleza",
"Belo Horizonte", "Manaus", "Curitiba", "Recife", "Goiania"
];
const CITIES_AU = [
"Sydney", "Melbourne", "Brisbane", "Perth", "Adelaide",
"Gold Coast", "Canberra", "Newcastle", "Wollongong", "Sunshine Coast"
];
const CITIES_CA = [
"Toronto", "Montreal", "Vancouver", "Calgary", "Edmonton",
"Ottawa", "Winnipeg", "Quebec City", "Hamilton", "Kitchener"
];
const CITIES_DEFAULT = [
"Other City"
];
const CITIES_BY_CODE = {
US: CITIES_US,
GB: CITIES_GB,
DE: CITIES_DE,
FR: CITIES_FR,
IT: CITIES_IT,
ES: CITIES_ES,
TR: CITIES_TR,
KZ: CITIES_KZ,
UA: CITIES_UA,
BY: CITIES_BY,
CN: CITIES_CN,
JP: CITIES_JP,
IN: CITIES_IN,
BR: CITIES_BR,
AU: CITIES_AU,
CA: CITIES_CA
};
function getCities(countryCode) {
return CITIES_BY_CODE[countryCode] || CITIES_DEFAULT;
}

126
public/data/countries.js Normal file
View File

@@ -0,0 +1,126 @@
const COUNTRIES = [
{ code: 'AF', name: 'Afghanistan', nameRu: 'Афганистан' },
{ code: 'AL', name: 'Albania', nameRu: 'Албания' },
{ code: 'DZ', name: 'Algeria', nameRu: 'Алжир' },
{ code: 'AD', name: 'Andorra', nameRu: 'Андорра' },
{ code: 'AO', name: 'Angola', nameRu: 'Ангола' },
{ code: 'AR', name: 'Argentina', nameRu: 'Аргентина' },
{ code: 'AM', name: 'Armenia', nameRu: 'Армения' },
{ code: 'AU', name: 'Australia', nameRu: 'Австралия' },
{ code: 'AT', name: 'Austria', nameRu: 'Австрия' },
{ code: 'AZ', name: 'Azerbaijan', nameRu: 'Азербайджан' },
{ code: 'BS', name: 'Bahamas', nameRu: 'Багамы' },
{ code: 'BH', name: 'Bahrain', nameRu: 'Бахрейн' },
{ code: 'BD', name: 'Bangladesh', nameRu: 'Бангладеш' },
{ code: 'BB', name: 'Barbados', nameRu: 'Барбадос' },
{ code: 'BY', name: 'Belarus', nameRu: 'Беларусь' },
{ code: 'BE', name: 'Belgium', nameRu: 'Бельгия' },
{ code: 'BZ', name: 'Belize', nameRu: 'Белиз' },
{ code: 'BJ', name: 'Benin', nameRu: 'Бенин' },
{ code: 'BT', name: 'Bhutan', nameRu: 'Бутан' },
{ code: 'BO', name: 'Bolivia', nameRu: 'Боливия' },
{ code: 'BA', name: 'Bosnia and Herzegovina', nameRu: 'Босния и Герцеговина' },
{ code: 'BW', name: 'Botswana', nameRu: 'Ботсвана' },
{ code: 'BR', name: 'Brazil', nameRu: 'Бразилия' },
{ code: 'BN', name: 'Brunei', nameRu: 'Бруней' },
{ code: 'BG', name: 'Bulgaria', nameRu: 'Болгария' },
{ code: 'KH', name: 'Cambodia', nameRu: 'Камбоджа' },
{ code: 'CM', name: 'Cameroon', nameRu: 'Камерун' },
{ code: 'CA', name: 'Canada', nameRu: 'Канада' },
{ code: 'CL', name: 'Chile', nameRu: 'Чили' },
{ code: 'CN', name: 'China', nameRu: 'Китай' },
{ code: 'CO', name: 'Colombia', nameRu: 'Колумбия' },
{ code: 'CR', name: 'Costa Rica', nameRu: 'Коста-Рика' },
{ code: 'HR', name: 'Croatia', nameRu: 'Хорватия' },
{ code: 'CU', name: 'Cuba', nameRu: 'Куба' },
{ code: 'CY', name: 'Cyprus', nameRu: 'Кипр' },
{ code: 'CZ', name: 'Czech Republic', nameRu: 'Чехия' },
{ code: 'DK', name: 'Denmark', nameRu: 'Дания' },
{ code: 'DO', name: 'Dominican Republic', nameRu: 'Доминиканская Республика' },
{ code: 'EC', name: 'Ecuador', nameRu: 'Эквадор' },
{ code: 'EG', name: 'Egypt', nameRu: 'Египет' },
{ code: 'SV', name: 'El Salvador', nameRu: 'Сальвадор' },
{ code: 'EE', name: 'Estonia', nameRu: 'Эстония' },
{ code: 'ET', name: 'Ethiopia', nameRu: 'Эфиопия' },
{ code: 'FI', name: 'Finland', nameRu: 'Финляндия' },
{ code: 'FR', name: 'France', nameRu: 'Франция' },
{ code: 'GE', name: 'Georgia', nameRu: 'Грузия' },
{ code: 'DE', name: 'Germany', nameRu: 'Германия' },
{ code: 'GH', name: 'Ghana', nameRu: 'Гана' },
{ code: 'GR', name: 'Greece', nameRu: 'Греция' },
{ code: 'GT', name: 'Guatemala', nameRu: 'Гватемала' },
{ code: 'HN', name: 'Honduras', nameRu: 'Гондурас' },
{ code: 'HK', name: 'Hong Kong', nameRu: 'Гонконг' },
{ code: 'HU', name: 'Hungary', nameRu: 'Венгрия' },
{ code: 'IS', name: 'Iceland', nameRu: 'Исландия' },
{ code: 'IN', name: 'India', nameRu: 'Индия' },
{ code: 'ID', name: 'Indonesia', nameRu: 'Индонезия' },
{ code: 'IR', name: 'Iran', nameRu: 'Иран' },
{ code: 'IQ', name: 'Iraq', nameRu: 'Ирак' },
{ code: 'IE', name: 'Ireland', nameRu: 'Ирландия' },
{ code: 'IL', name: 'Israel', nameRu: 'Израиль' },
{ code: 'IT', name: 'Italy', nameRu: 'Италия' },
{ code: 'JM', name: 'Jamaica', nameRu: 'Ямайка' },
{ code: 'JP', name: 'Japan', nameRu: 'Япония' },
{ code: 'JO', name: 'Jordan', nameRu: 'Иордания' },
{ code: 'KZ', name: 'Kazakhstan', nameRu: 'Казахстан' },
{ code: 'KE', name: 'Kenya', nameRu: 'Кения' },
{ code: 'KR', name: 'South Korea', nameRu: 'Южная Корея' },
{ code: 'KW', name: 'Kuwait', nameRu: 'Кувейт' },
{ code: 'LV', name: 'Latvia', nameRu: 'Латвия' },
{ code: 'LB', name: 'Lebanon', nameRu: 'Ливан' },
{ code: 'LT', name: 'Lithuania', nameRu: 'Литва' },
{ code: 'LU', name: 'Luxembourg', nameRu: 'Люксембург' },
{ code: 'MO', name: 'Macau', nameRu: 'Макао' },
{ code: 'MY', name: 'Malaysia', nameRu: 'Малайзия' },
{ code: 'MV', name: 'Maldives', nameRu: 'Мальдивы' },
{ code: 'MT', name: 'Malta', nameRu: 'Мальта' },
{ code: 'MX', name: 'Mexico', nameRu: 'Мексика' },
{ code: 'MD', name: 'Moldova', nameRu: 'Молдова' },
{ code: 'MC', name: 'Monaco', nameRu: 'Монако' },
{ code: 'MN', name: 'Mongolia', nameRu: 'Монголия' },
{ code: 'ME', name: 'Montenegro', nameRu: 'Черногория' },
{ code: 'MA', name: 'Morocco', nameRu: 'Марокко' },
{ code: 'MM', name: 'Myanmar', nameRu: 'Мьянма' },
{ code: 'NP', name: 'Nepal', nameRu: 'Непал' },
{ code: 'NL', name: 'Netherlands', nameRu: 'Нидерланды' },
{ code: 'NZ', name: 'New Zealand', nameRu: 'Новая Зеландия' },
{ code: 'NG', name: 'Nigeria', nameRu: 'Нигерия' },
{ code: 'NO', name: 'Norway', nameRu: 'Норвегия' },
{ code: 'OM', name: 'Oman', nameRu: 'Оман' },
{ code: 'PK', name: 'Pakistan', nameRu: 'Пакистан' },
{ code: 'PA', name: 'Panama', nameRu: 'Панама' },
{ code: 'PY', name: 'Paraguay', nameRu: 'Парагвай' },
{ code: 'PE', name: 'Peru', nameRu: 'Перу' },
{ code: 'PH', name: 'Philippines', nameRu: 'Филиппины' },
{ code: 'PL', name: 'Poland', nameRu: 'Польша' },
{ code: 'PT', name: 'Portugal', nameRu: 'Португалия' },
{ code: 'QA', name: 'Qatar', nameRu: 'Катар' },
{ code: 'RO', name: 'Romania', nameRu: 'Румыния' },
{ code: 'RU', name: 'Russia', nameRu: 'Россия' },
{ code: 'SA', name: 'Saudi Arabia', nameRu: 'Саудовская Аравия' },
{ code: 'RS', name: 'Serbia', nameRu: 'Сербия' },
{ code: 'SG', name: 'Singapore', nameRu: 'Сингапур' },
{ code: 'SK', name: 'Slovakia', nameRu: 'Словакия' },
{ code: 'SI', name: 'Slovenia', nameRu: 'Словения' },
{ code: 'ZA', name: 'South Africa', nameRu: 'Южная Африка' },
{ code: 'ES', name: 'Spain', nameRu: 'Испания' },
{ code: 'LK', name: 'Sri Lanka', nameRu: 'Шри-Ланка' },
{ code: 'SD', name: 'Sudan', nameRu: 'Судан' },
{ code: 'SE', name: 'Sweden', nameRu: 'Швеция' },
{ code: 'CH', name: 'Switzerland', nameRu: 'Швейцария' },
{ code: 'TW', name: 'Taiwan', nameRu: 'Тайвань' },
{ code: 'TH', name: 'Thailand', nameRu: 'Таиланд' },
{ code: 'TN', name: 'Tunisia', nameRu: 'Тунис' },
{ code: 'TR', name: 'Turkey', nameRu: 'Турция' },
{ code: 'UA', name: 'Ukraine', nameRu: 'Украина' },
{ code: 'AE', name: 'United Arab Emirates', nameRu: 'Объединенные Арабские Эмираты' },
{ code: 'GB', name: 'United Kingdom', nameRu: 'Великобритания' },
{ code: 'US', name: 'United States', nameRu: 'США' },
{ code: 'UY', name: 'Uruguay', nameRu: 'Уругвай' },
{ code: 'UZ', name: 'Uzbekistan', nameRu: 'Узбекистан' },
{ code: 'VE', name: 'Venezuela', nameRu: 'Венесуэла' },
{ code: 'VN', name: 'Vietnam', nameRu: 'Вьетнам' },
{ code: 'YE', name: 'Yemen', nameRu: 'Йемен' },
{ code: 'ZW', name: 'Zimbabwe', nameRu: 'Зимбабве' }
];

View File

@@ -9,6 +9,7 @@
<link rel="stylesheet" href="css/playfair-font.css">
<link rel="stylesheet" href="css/fontawesome.min.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/reviews.css">
</head>
<body>
@@ -35,6 +36,10 @@
<li class="nav-item"><a class="nav-link" href="#host">Хозяин</a></li>
<li class="nav-item"><a class="nav-link" href="#contact">Контакты</a></li>
<li class="nav-item"><a class="nav-link" href="/admin" style="color: var(--gold);"><i class="fas fa-cog"></i> Управление</a></li>
<li class="nav-item language-switcher">
<button class="lang-btn active" data-lang="ru" onclick="switchLang('ru')">RU</button>
<button class="lang-btn" data-lang="en" onclick="switchLang('en')">EN</button>
</li>
</ul>
</div>
</div>
@@ -331,105 +336,35 @@
</section>
<!-- Reviews Section -->
<section class="section section-dark" id="reviews">
<section class="section reviews-section" id="reviews">
<div class="container">
<div class="section-header animate-on-scroll">
<div class="section-badge">Отзывы</div>
<h2 class="section-title">Что говорят наши гости</h2>
<div class="section-badge" data-i18n="reviews.title">Отзывы</div>
<h2 class="section-title" data-i18n="reviews.title">Что говорят наши гости</h2>
<div class="abkhazian-pattern"></div>
<p class="section-subtitle">Более 500 счастливых гостей рекомендуют Hotel 777</p>
</div>
<div class="row g-4">
<div class="col-lg-4 col-md-6 animate-on-scroll">
<div class="review-card">
<div class="review-stars">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i>
</div>
<p class="review-text">«Невероятное место! Чистейшее море, уютные номера и потрясающая абхазская кухня в столовой. Рауф Алексеевич — настоящий хозяин, встретил как родного. Обязательно вернёмся!»</p>
<div class="review-author">
<div class="review-avatar">АК</div>
<div class="reviews-stats" id="reviewsStats" style="display: none;">
<div class="reviews-avg">
<div class="reviews-avg-number" id="reviewsAvgNumber">0.0</div>
<div>
<div class="review-name">Анна Козлова</div>
<div class="review-date">Москва • Август 2025</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 animate-on-scroll">
<div class="review-card">
<div class="review-stars">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i>
</div>
<p class="review-text">«Брали VIP-номер — две комнаты, всё идеально. Кондиционер, WiFi, горячая вода — всё работает. SUP-борды брали в аренду, плавали вдоль побережья. Рауф Алексеевич помог с экскурсиями. Незабываемо!»</p>
<div class="review-author">
<div class="review-avatar">ДМ</div>
<div>
<div class="review-name">Дмитрий Морозов</div>
<div class="review-date">Санкт-Петербург • Июль 2025</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 animate-on-scroll">
<div class="review-card">
<div class="review-stars">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i>
</div>
<p class="review-text">«Эконом-номер превзошёл ожидания — чистый, свежий, с видом на сад. До пляжа 5 минут, кафе рядом. Абхазия — это любовь с первого взгляда. Спасибо Рауфу Алексеевичу и Hotel 777!»</p>
<div class="review-author">
<div class="review-avatar">ЕС</div>
<div>
<div class="review-name">Елена Смирнова</div>
<div class="review-date">Казань • Сентябрь 2025</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 animate-on-scroll">
<div class="review-card">
<div class="review-stars">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i>
</div>
<p class="review-text">«Отдыхали семьёй в стандарте — две кровати, детям понравилось. Рауф Алексеевич — супер-хозяин! Накормил так, что мы захотели остаться навсегда. Природа вокруг — просто сказка. Рекомендую всем!»</p>
<div class="review-author">
<div class="review-avatar">ИП</div>
<div>
<div class="review-name">Игорь Петров</div>
<div class="review-date">Сочи • Июнь 2025</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 animate-on-scroll">
<div class="review-card">
<div class="review-stars">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i>
</div>
<p class="review-text">«Второй раз приезжаем в Hotel 777 и снова в восторге! Мгудзырхуа — тихое, красивое село. Море чистое, Рауф Алексеевич — гостеприимный хозяин. Это лучшая гостиница в Гудаутском районе!»</p>
<div class="review-author">
<div class="review-avatar">МВ</div>
<div>
<div class="review-name">Мария Волкова</div>
<div class="review-date">Новосибирск • Май 2025</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 animate-on-scroll">
<div class="review-card">
<div class="review-stars">
<i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i>
</div>
<p class="review-text">«Катались на SUP-бордах каждый день! Море спокойное, вода прозрачная. Номер был просторный, кровать удобная. Рауф Алексеевич организовал нам экскурсии по Абхазии. Лучший отпуск в жизни!»</p>
<div class="review-author">
<div class="review-avatar">ОН</div>
<div>
<div class="review-name">Олег Новиков</div>
<div class="review-date">Екатеринбург • Август 2025</div>
<div class="reviews-avg-stars" id="reviewsAvgStars"></div>
<div class="reviews-avg-count" id="reviewsAvgCount"></div>
</div>
</div>
</div>
<div class="reviews-grid" id="reviewsGrid"></div>
<div class="reviews-empty" id="reviewsEmpty" style="display: none;">
<i class="fas fa-star-half-alt"></i>
<p data-i18n="reviews.write_first">Будьте первым, кто оставит отзыв!</p>
</div>
<div class="reviews-actions">
<button class="btn-gold" onclick="openReviewModal()" data-i18n="reviews.leave_review">
<i class="fas fa-pen"></i> Оставить отзыв
</button>
</div>
</div>
</section>
@@ -686,6 +621,91 @@
<script src="js/bootstrap.bundle.min.js"></script>
<script src="js/main.js"></script>
<script src="js/i18n.js"></script>
<script src="js/reviews.js"></script>
<!-- Review Modal -->
<div class="review-modal" id="reviewModal">
<div class="review-modal-content">
<div class="review-modal-header">
<h3 data-i18n="reviews.write_review">Написать отзыв</h3>
<button class="review-modal-close" onclick="closeReviewModal()">&times;</button>
</div>
<div class="review-modal-body" id="reviewModalBody">
<form id="reviewForm">
<div class="review-row">
<div class="review-form-group">
<label data-i18n="form.select_country">Страна</label>
<div class="review-select-wrapper">
<select class="review-form-control" id="reviewCountry" required>
<option value="" data-i18n="form.select_country">Выберите страну</option>
</select>
</div>
<div class="review-error" id="countryError" data-i18n="validation.country_required">Выберите страну</div>
</div>
<div class="review-form-group">
<label data-i18n="form.select_city">Город (необязательно)</label>
<div class="review-select-wrapper" id="citySelectWrapper">
<select class="review-form-control" id="reviewCity">
<option value="" data-i18n="form.select_city">Выберите город</option>
</select>
</div>
<div class="review-error" id="cityError" data-i18n="validation.city_required">Выберите или введите город</div>
</div>
</div>
<div class="review-form-group" id="otherCityGroup" style="display: none;">
<label data-i18n="form.another_city">Другой город</label>
<input type="text" class="review-form-control" id="reviewOtherCity" data-i18n-placeholder="form.city_placeholder" placeholder="Введите название города">
<div class="review-error" data-i18n="validation.city_required">Введите название города</div>
</div>
<div class="review-form-group">
<label data-i18n="form.rating">Оценка</label>
<div class="review-stars-input">
<div class="review-stars-slider-wrapper">
<div class="review-stars-display" id="starsDisplay"></div>
<div class="review-stars-value" id="starsValue">5.0</div>
</div>
<input type="range" class="review-stars-slider" id="starsSlider" min="0" max="5" step="0.1" value="5" style="--value: 100%;">
</div>
<div class="review-error" data-i18n="validation.rating_required">Поставьте оценку</div>
</div>
<div class="review-form-group">
<label data-i18n="form.your_name">Ваше имя (ФИО) <span class="required">*</span></label>
<input type="text" class="review-form-control" id="reviewName" required data-i18n-placeholder="form.name_placeholder" placeholder="Иванов Иван Иванович" minlength="2">
<div class="review-error" data-i18n="validation.name_min">Имя должно содержать минимум 2 символа</div>
</div>
<div class="review-form-group">
<label data-i18n="form.review_text">Текст отзыва <span class="required">*</span></label>
<textarea class="review-form-control" id="reviewText" rows="4" required data-i18n-placeholder="form.review_placeholder" placeholder="Расскажите о вашем опыте отдыха в Hotel 777..." minlength="20"></textarea>
<div class="review-error" id="textError" data-i18n="validation.review_min">Отзыв должен содержать минимум 20 символов</div>
</div>
<div class="review-form-group">
<label data-i18n="form.hotel_code">Код гостиницы <span class="required">*</span></label>
<div class="review-code-wrapper">
<input type="password" class="review-form-control" id="reviewCode" required data-i18n-placeholder="form.code_placeholder" placeholder="Получите на ресепшене">
<button type="button" class="toggle-code" onclick="toggleCodeVisibility()">
<i class="fas fa-eye"></i>
</button>
</div>
<small class="hint" data-i18n="form.code_hint">Код сообщается гостям на ресепшене отеля</small>
<div class="review-error" id="codeError" data-i18n="validation.code_invalid">Неверный код гостиницы</div>
</div>
</form>
</div>
<div class="review-modal-footer">
<button type="button" class="review-btn-cancel" onclick="closeReviewModal()" data-i18n="form.cancel">Отмена</button>
<button type="submit" class="review-btn-submit" form="reviewForm" id="reviewSubmitBtn">
<span data-i18n="form.submit">Отправить отзыв</span>
</button>
</div>
</div>
</div>
</body>
</html>

110
public/js/i18n.js Normal file
View File

@@ -0,0 +1,110 @@
const I18n = {
currentLang: 'ru',
translations: {},
async init() {
const savedLang = localStorage.getItem('lang') || 'ru';
await this.setLang(savedLang);
},
async setLang(lang) {
try {
const res = await fetch(`/api/translations/${lang}`);
if (!res.ok) throw new Error('Failed to load translations');
this.translations = await res.json();
this.currentLang = lang;
localStorage.setItem('lang', lang);
this.updateUI();
this.updateNavLabels();
} catch (err) {
console.error('I18n error:', err);
if (lang !== 'ru') {
await this.setLang('ru');
}
}
},
t(key, replacements = {}) {
let text = this.translations[key] || key;
Object.entries(replacements).forEach(([k, v]) => {
text = text.replace(`{${k}}`, v);
});
return text;
},
updateUI() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const attr = el.getAttribute('data-i18n-attr');
const text = this.t(key);
if (attr) {
el.setAttribute(attr, text);
} else {
el.textContent = text;
}
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.placeholder = this.t(key);
});
},
updateNavLabels() {
const langBtns = document.querySelectorAll('.lang-btn');
langBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.lang === this.currentLang);
});
},
getInitials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
},
formatDate(dateStr) {
const date = new Date(dateStr);
const months = this.currentLang === 'ru'
? ['Января', 'Февраля', 'Марта', 'Апреля', 'Мая', 'Июня', 'Июля', 'Августа', 'Сентября', 'Октября', 'Ноября', 'Декабря']
: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return `${months[date.getMonth()]} ${date.getFullYear()}`;
},
renderStars(count, max = 5) {
let html = '';
for (let i = 1; i <= max; i++) {
if (count >= i) {
html += '<i class="fas fa-star filled"></i>';
} else if (count >= i - 0.5) {
html += '<i class="fas fa-star-half-alt filled"></i>';
} else {
html += '<i class="far fa-star"></i>';
}
}
return html;
},
renderStarsStatic(count) {
let html = '';
const fullStars = Math.floor(count);
const hasHalf = count % 1 >= 0.5;
for (let i = 0; i < 5; i++) {
if (i < fullStars) {
html += '<i class="fas fa-star"></i>';
} else if (i === fullStars && hasHalf) {
html += '<i class="fas fa-star-half-alt"></i>';
} else {
html += '<i class="far fa-star"></i>';
}
}
return html;
}
};
window.I18n = I18n;

550
public/js/reviews.js Normal file
View File

@@ -0,0 +1,550 @@
async function loadReviews() {
const lang = I18n.currentLang;
try {
const res = await fetch(`/api/reviews?lang=${lang}`);
if (!res.ok) throw new Error('Failed to load reviews');
const data = await res.json();
renderReviews(data.reviews, data.stats);
} catch (err) {
console.error('Error loading reviews:', err);
document.getElementById('reviewsEmpty').style.display = 'block';
}
}
async function loadCountries() {
try {
const res = await fetch('/api/countries');
const data = await res.json();
if (Array.isArray(data)) {
return data;
}
} catch (err) {
console.error('Error loading countries:', err);
}
return [];
}
async function loadCities(countryCode) {
try {
const res = await fetch(`/api/cities/${countryCode}`);
const data = await res.json();
const result = {
popular: data.popular || [],
cities: data.cities || []
};
if (result.popular.length > 0 || result.cities.length > 0) {
return result;
}
} catch (err) {
console.error('Error loading cities:', err);
}
return { popular: [], cities: [] };
}
function renderReviews(reviews, stats) {
const grid = document.getElementById('reviewsGrid');
const statsEl = document.getElementById('reviewsStats');
const emptyEl = document.getElementById('reviewsEmpty');
if (!grid || !emptyEl) return;
if (!reviews || reviews.length === 0) {
if (statsEl) statsEl.style.display = 'none';
grid.innerHTML = '';
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
if (statsEl) statsEl.style.display = 'flex';
const avgStars = stats.avgStars.toFixed(1);
const avgNumEl = document.getElementById('reviewsAvgNumber');
const avgStarsEl = document.getElementById('reviewsAvgStars');
const avgCountEl = document.getElementById('reviewsAvgCount');
if (avgNumEl) avgNumEl.textContent = avgStars;
if (avgStarsEl) avgStarsEl.innerHTML = I18n.renderStarsStatic(parseFloat(avgStars));
if (avgCountEl) avgCountEl.textContent = `${stats.count} ${getReviewWord(stats.count)}`;
grid.innerHTML = reviews.map(review => {
const initials = I18n.getInitials(review.author_name);
const stars = I18n.renderStarsStatic(review.stars);
const date = review.created_at ? I18n.formatDate(review.created_at) : '';
const countryPart = review.country || '';
const cityPart = review.city ? `, ${review.city}` : '';
const location = countryPart || cityPart ? `${countryPart}${cityPart}` : '';
return `
<div class="review-card animate-on-scroll">
<div class="review-card-header">
<div class="review-author">
<div class="review-avatar">${initials}</div>
<div class="review-author-info">
<div class="review-author-name">${escapeHtml(review.author_name)}</div>
${location ? `<div class="review-author-location">${escapeHtml(location)}</div>` : ''}
</div>
</div>
<div class="review-stars">${stars}</div>
</div>
<p class="review-text">«${escapeHtml(review.text)}»</p>
${date ? `<div class="review-date">${date}</div>` : ''}
</div>
`;
}).join('');
setTimeout(() => {
document.querySelectorAll('.review-card.animate-on-scroll').forEach(el => {
el.classList.add('visible');
});
}, 100);
}
function getReviewWord(count) {
const lastTwo = count % 100;
const lastOne = count % 10;
if (I18n.currentLang === 'en') {
return lastTwo === 11 ? 'reviews' : lastOne === 1 ? 'review' : 'reviews';
}
if (lastTwo >= 11 && lastTwo <= 14) return 'отзывов';
if (lastOne === 1) return 'отзыв';
if (lastOne >= 2 && lastOne <= 4) return 'отзыва';
return 'отзывов';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function openReviewModal() {
const modal = document.getElementById('reviewModal');
if (!modal) return;
await loadCountriesAndCities();
modal.classList.add('show');
document.body.style.overflow = 'hidden';
initStarsSlider();
resetReviewForm();
}
function closeReviewModal() {
const modal = document.getElementById('reviewModal');
if (!modal) return;
modal.classList.remove('show');
document.body.style.overflow = '';
}
let countriesCache = [];
let popularCountriesCache = [];
async function loadPopularCountries() {
try {
const res = await fetch('/api/reviews/popular');
const data = await res.json();
return data;
} catch (err) {
console.error('Error loading popular countries:', err);
}
return { countries: [], cities: {} };
}
async function loadCountriesAndCities() {
const popularData = await loadPopularCountries();
popularCountriesCache = popularData.countries || [];
if (countriesCache.length === 0) {
countriesCache = await loadCountries();
}
populateCountrySelect();
}
function populateCountrySelect() {
const select = document.getElementById('reviewCountry');
if (!select) return;
select.innerHTML = '<option value="">' + I18n.t('form.select_country') + '</option>';
const popularCodes = popularCountriesCache.map(c => c.country_code);
const popularCountries = [];
const otherCountries = [];
countriesCache.forEach(country => {
if (popularCodes.includes(country.code)) {
popularCountries.push(country);
} else {
otherCountries.push(country);
}
});
otherCountries.sort((a, b) => {
const nameA = I18n.currentLang === 'ru' ? a.nameRu : a.name;
const nameB = I18n.currentLang === 'ru' ? b.nameRu : b.name;
return nameA.localeCompare(nameB);
});
if (popularCountries.length > 0) {
const popularGroup = document.createElement('optgroup');
popularGroup.label = I18n.t('select.popular');
popularCountries.forEach(country => {
const opt = document.createElement('option');
opt.value = country.code;
opt.textContent = I18n.currentLang === 'ru' ? country.nameRu : country.name;
popularGroup.appendChild(opt);
});
select.appendChild(popularGroup);
}
const otherGroup = document.createElement('optgroup');
otherGroup.label = I18n.t('select.alphabetical');
otherCountries.forEach(country => {
const opt = document.createElement('option');
opt.value = country.code;
opt.textContent = I18n.currentLang === 'ru' ? country.nameRu : country.name;
otherGroup.appendChild(opt);
});
select.appendChild(otherGroup);
}
let currentCountryCode = null;
let citiesCache = [];
function setupCountryChangeHandler() {
const countrySelect = document.getElementById('reviewCountry');
if (countrySelect) {
countrySelect.addEventListener('change', async function() {
const countryCode = this.value;
const citySelect = document.getElementById('reviewCity');
const cityWrapper = document.getElementById('citySelectWrapper');
const otherCityGroup = document.getElementById('otherCityGroup');
if (!countryCode) {
citySelect.innerHTML = '<option value="">' + I18n.t('form.select_city') + '</option>';
citySelect.disabled = true;
otherCityGroup.style.display = 'none';
return;
}
citySelect.innerHTML = '<option value="">' + I18n.t('form.select_city') + '</option>';
citySelect.disabled = true;
const cityData = await loadCities(countryCode);
if (cityData.popular.length > 0) {
const groupOpt = document.createElement('optgroup');
groupOpt.label = I18n.t('select.popular');
cityData.popular.forEach(city => {
const opt = document.createElement('option');
opt.value = city;
opt.textContent = city;
groupOpt.appendChild(opt);
});
citySelect.appendChild(groupOpt);
}
if (cityData.cities.length > 0) {
const alphaOpt = document.createElement('optgroup');
alphaOpt.label = I18n.t('select.alphabetical');
cityData.cities.forEach(city => {
const opt = document.createElement('option');
opt.value = city;
opt.textContent = city;
alphaOpt.appendChild(opt);
});
citySelect.appendChild(alphaOpt);
}
const otherOpt = document.createElement('option');
otherOpt.value = '__other__';
otherOpt.textContent = I18n.t('form.another_city');
citySelect.appendChild(otherOpt);
citySelect.disabled = false;
cityWrapper.style.display = 'block';
otherCityGroup.style.display = 'none';
currentCountryCode = countryCode;
});
}
}
function setupCityChangeHandler() {
const citySelect = document.getElementById('reviewCity');
if (citySelect) {
citySelect.addEventListener('change', function() {
const otherCityGroup = document.getElementById('otherCityGroup');
if (this.value === '__other__') {
otherCityGroup.style.display = 'block';
document.getElementById('reviewOtherCity').focus();
} else {
otherCityGroup.style.display = 'none';
}
});
}
}
function initStarsSlider() {
const slider = document.getElementById('starsSlider');
const display = document.getElementById('starsDisplay');
const valueEl = document.getElementById('starsValue');
if (!slider || !display || !valueEl) return;
function updateStars(val) {
const stars = parseFloat(val);
const fullStars = Math.floor(stars);
const hasHalf = (stars % 1) >= 0.5;
const percent = (stars / 5) * 100;
slider.style.setProperty('--value', `${percent}%`);
valueEl.textContent = stars.toFixed(1);
let html = '';
for (let i = 0; i < 5; i++) {
if (i < fullStars) {
html += '<i class="fas fa-star filled"></i>';
} else if (i === fullStars && hasHalf) {
html += '<i class="fas fa-star-half-alt filled"></i>';
} else {
html += '<i class="far fa-star"></i>';
}
}
display.innerHTML = html;
}
slider.addEventListener('input', () => updateStars(slider.value));
updateStars(slider.value);
}
function toggleCodeVisibility() {
const input = document.getElementById('reviewCode');
const btn = document.querySelector('.toggle-code i');
if (input.type === 'password') {
input.type = 'text';
btn.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
btn.className = 'fas fa-eye';
}
}
function resetReviewForm() {
const form = document.getElementById('reviewForm');
const countryEl = document.getElementById('reviewCountry');
const cityEl = document.getElementById('reviewCity');
const cityWrapper = document.getElementById('citySelectWrapper');
const otherCityGroup = document.getElementById('otherCityGroup');
const starsSlider = document.getElementById('starsSlider');
const codeInput = document.getElementById('reviewCode');
const toggleBtn = document.querySelector('.toggle-code i');
const modalBody = document.getElementById('reviewModalBody');
if (form) form.reset();
if (countryEl) countryEl.value = '';
if (cityEl) cityEl.innerHTML = '<option value="">Выберите город</option>';
if (cityWrapper) cityWrapper.style.display = 'block';
if (otherCityGroup) otherCityGroup.style.display = 'none';
if (starsSlider) starsSlider.value = 5;
if (codeInput) codeInput.type = 'password';
if (toggleBtn) toggleBtn.className = 'fas fa-eye';
if (form) {
form.querySelectorAll('.review-error').forEach(el => el.classList.remove('show'));
form.querySelectorAll('.review-form-control').forEach(el => el.classList.remove('error'));
}
const successEl = document.getElementById('reviewSuccessMessage');
if (successEl) successEl.remove();
if (modalBody && form) {
modalBody.innerHTML = form.outerHTML;
}
initStarsSlider();
setupReviewFormSubmit();
}
function getReviewCity() {
const citySelect = document.getElementById('reviewCity');
if (!citySelect || citySelect.value === '' || citySelect.value === '__other__') {
return document.getElementById('reviewOtherCity').value.trim() || '';
}
return citySelect.value;
}
function getReviewCountry() {
const select = document.getElementById('reviewCountry');
if (!select) return '';
const selectedOption = select.options[select.selectedIndex];
return selectedOption ? selectedOption.textContent : '';
}
function validateReviewForm() {
const form = document.getElementById('reviewForm');
let isValid = true;
if (!form) return false;
form.querySelectorAll('.review-error').forEach(el => el.classList.remove('show'));
form.querySelectorAll('.review-form-control').forEach(el => el.classList.remove('error'));
const country = document.getElementById('reviewCountry').value;
if (!country) {
document.getElementById('reviewCountry').classList.add('error');
document.getElementById('countryError').classList.add('show');
isValid = false;
}
const city = getReviewCity();
const nameEl = document.getElementById('reviewName');
const name = nameEl ? nameEl.value.trim() : '';
if (!name || name.length < 2) {
if (nameEl) nameEl.classList.add('error');
const nameError = nameEl ? nameEl.nextElementSibling : null;
if (nameError) nameError.classList.add('show');
isValid = false;
}
const textEl = document.getElementById('reviewText');
const text = textEl ? textEl.value.trim() : '';
if (!text || text.length < 20) {
if (textEl) textEl.classList.add('error');
document.getElementById('textError').classList.add('show');
isValid = false;
}
const codeEl = document.getElementById('reviewCode');
const code = codeEl ? codeEl.value.trim() : '';
if (!code) {
if (codeEl) codeEl.classList.add('error');
const codeError = document.getElementById('codeError');
if (codeError) {
codeError.textContent = I18n.t('validation.code_required');
codeError.classList.add('show');
}
isValid = false;
}
return isValid;
}
function setupReviewFormSubmit() {
const form = document.getElementById('reviewForm');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (!validateReviewForm()) return;
const submitBtn = document.getElementById('reviewSubmitBtn');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span><span>Отправка...</span>';
}
const nameEl = document.getElementById('reviewName');
const countryEl = document.getElementById('reviewCountry');
const starsEl = document.getElementById('starsSlider');
const textEl = document.getElementById('reviewText');
const codeEl = document.getElementById('reviewCode');
const data = {
author_name: nameEl ? nameEl.value.trim() : '',
country_code: countryEl ? countryEl.value : '',
country_name: getReviewCountry(),
city: getReviewCity(),
stars: starsEl ? parseFloat(starsEl.value) : 5,
text: textEl ? textEl.value.trim() : '',
review_code: codeEl ? codeEl.value.trim() : ''
};
try {
const res = await fetch('/api/reviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (!res.ok) {
throw new Error(result.error || 'Failed to submit review');
}
showReviewSuccess();
} catch (err) {
const codeError = document.getElementById('codeError');
if (err.message.includes('code') || err.message.includes('Invalid')) {
if (codeError) codeError.textContent = I18n.t('validation.code_invalid');
} else if (err.message.includes('recently')) {
if (codeError) codeError.textContent = I18n.t('validation.too_frequent');
} else {
if (codeError) codeError.textContent = err.message;
}
if (codeError) codeError.classList.add('show');
const codeInput = document.getElementById('reviewCode');
if (codeInput) codeInput.classList.add('error');
} finally {
const submitBtn = document.getElementById('reviewSubmitBtn');
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = '<span data-i18n="form.submit">Отправить отзыв</span>';
}
}
});
}
function showReviewSuccess() {
const body = document.getElementById('reviewModalBody');
if (!body) return;
body.innerHTML = `
<div class="review-success-message">
<i class="fas fa-check-circle"></i>
<h4>${I18n.t('validation.success').split('.')[0]}</h4>
<p>${I18n.t('validation.success').split('.')[1] || ''}</p>
</div>
`;
setTimeout(() => {
closeReviewModal();
}, 3000);
}
async function switchLang(lang) {
await I18n.setLang(lang);
loadReviews();
}
document.addEventListener('DOMContentLoaded', async () => {
await I18n.init();
setupReviewFormSubmit();
setupCountryChangeHandler();
setupCityChangeHandler();
loadReviews();
});
const reviewModal = document.getElementById('reviewModal');
if (reviewModal) {
reviewModal.addEventListener('click', function(e) {
if (e.target === this) {
closeReviewModal();
}
});
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeReviewModal();
}
});

133
server.js
View File

@@ -203,6 +203,50 @@ db.serialize(() => {
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.run(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) {
console.error('CREATE TABLE settings FAILED:', err.message);
} else {
console.log('CREATE TABLE settings SUCCESS');
}
});
db.run(`CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_name TEXT NOT NULL,
country TEXT NOT NULL,
country_code TEXT,
city TEXT NOT NULL,
stars REAL NOT NULL CHECK(stars >= 0 AND stars <= 5),
text TEXT NOT NULL,
review_code TEXT NOT NULL,
ip_address TEXT,
is_approved INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err && !err.message.includes('already exists')) {
console.error('Reviews table error:', err.message);
}
db.run(`PRAGMA foreign_keys = ON`, (err) => {
if (err) console.error('Foreign keys error:', err.message);
});
db.all("PRAGMA table_info(reviews)", [], (err, cols) => {
if (err || !cols) return;
const colNames = cols.map(c => c.name);
if (!colNames.includes('country_code')) {
db.run("ALTER TABLE reviews ADD COLUMN country_code TEXT", (err) => {
if (err) console.log('Migration: country_code column (ignore if exists):', err.message);
else console.log('Migration: country_code column added');
});
}
});
});
});
const modules = {};
@@ -213,6 +257,9 @@ const adminBookingsModule = require('./modules/adminBookings');
const promocodesModule = require('./modules/promocodes');
const roomsModule = require('./modules/rooms');
const usersModule = require('./modules/users');
const settingsModule = require('./modules/settings');
const reviewsModule = require('./modules/reviews');
const translationsModule = require('./modules/translations');
const { runStartupTests } = require('./tests/runStartupTests');
modules.auth = authModule;
@@ -221,6 +268,9 @@ modules.promocodes = promocodesModule;
modules.rooms = roomsModule;
modules.users = usersModule;
modules.adminBookings = adminBookingsModule;
modules.settings = settingsModule;
modules.reviews = reviewsModule;
modules.translations = translationsModule;
authModule.init(db, JWT_SECRET);
bookingsModule.init(db);
@@ -228,6 +278,8 @@ adminBookingsModule.init(db);
promocodesModule.init(db);
roomsModule.init(db);
usersModule.init(db, bcrypt);
settingsModule.init(db);
reviewsModule.init(db, settingsModule);
function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
@@ -279,6 +331,87 @@ adminBookingsModule.setupRoutes(app, authModule.authenticateToken, authModule.re
promocodesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
roomsModule.setupRoutes(app);
usersModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
settingsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
reviewsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
app.get('/api/translations/:lang', (req, res) => {
const lang = req.params.lang;
const translations = translationsModule.getTranslations(lang);
if (!translations) {
return res.status(404).json({ error: 'Language not found' });
}
res.json(translations);
});
app.get('/api/countries-cities', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'data', 'countries-cities.js'));
});
app.get('/api/countries', (req, res) => {
const countriesPath = path.join(__dirname, 'public', 'data', 'countries.js');
fs.readFile(countriesPath, 'utf8', (err, data) => {
if (err) return res.status(500).json({ error: 'File not found' });
const match = data.match(/const\s+COUNTRIES\s*=\s*(\[.*\]);/s);
if (match) {
try {
const countries = eval(match[1]);
res.json(countries);
} catch (e) {
res.status(500).json({ error: 'Parse error' });
}
} else {
res.status(500).json({ error: 'Parse error' });
}
});
});
app.get('/api/cities/:countryCode', (req, res) => {
const countryCode = req.params.countryCode;
const citiesPath = path.join(__dirname, 'public', 'data', 'cities', `${countryCode}.js`);
const majorCitiesPath = path.join(__dirname, 'public', 'data', 'cities', 'major.js');
const filePath = fs.existsSync(citiesPath) ? citiesPath : (fs.existsSync(majorCitiesPath) ? majorCitiesPath : null);
if (!filePath) {
return res.json({ cities: [], popular: [], countryCode });
}
try {
const data = fs.readFileSync(filePath, 'utf8');
let cities = [];
const arrayMatch = data.match(/const\s+CITIES_\w+\s*=\s*(\[.*?\]);/s);
if (arrayMatch) {
cities = JSON.parse(arrayMatch[1].replace(/"/g, '"').replace(/'/g, "'"));
}
if (cities.length === 0) {
const majorMatch = data.match(/CITIES_BY_CODE\s*=\s*({[\s\S]*?});/);
if (majorMatch) {
const codeMatch = majorMatch[1].match(new RegExp(`${countryCode}:\\s*\\[([^\\]]+)\\]`));
if (codeMatch) {
cities = codeMatch[1].split(',').map(c => c.trim().replace(/^["']|["']$/g, ''));
}
}
}
if (cities.length === 0) {
const defaultMatch = data.match(/CITIES_DEFAULT\s*=\s*(\[.*?\]);/s);
if (defaultMatch) {
try {
const evalResult = eval(defaultMatch[1]);
if (Array.isArray(evalResult)) {
cities = evalResult;
}
} catch (e) {}
}
}
res.json({ cities, popular: [], countryCode });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));

23
test_api.js Normal file
View File

@@ -0,0 +1,23 @@
const http = require('http');
const options = {
hostname: 'localhost',
port: 3000,
path: '/api/reviews/popular',
method: 'GET'
};
const req = http.request(options, (res) => {
console.log('Status:', res.statusCode);
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
console.log('Response:', data);
});
});
req.on('error', (e) => {
console.error('Error:', e.message);
});
req.end();

View File

@@ -103,6 +103,38 @@ function runStartupTests(db, modules) {
addResult('Module: adminBookings', 'FAIL', 'Not loaded');
}
if (modules.settings) {
addResult('Module: settings', 'OK', 'Initialized');
} else {
addResult('Module: settings', 'FAIL', 'Not loaded');
}
if (modules.reviews) {
addResult('Module: reviews', 'OK', 'Initialized');
} else {
addResult('Module: reviews', 'FAIL', 'Not loaded');
}
if (modules.translations) {
addResult('Module: translations', 'OK', 'Initialized');
} else {
addResult('Module: translations', 'FAIL', 'Not loaded');
}
db.get(`SELECT name FROM sqlite_master WHERE type='table' AND name='settings'`, [], (err, row) => {
if (err || !row) {
addResult('Table: settings', 'FAIL', 'Not found');
} else {
addResult('Table: settings', 'OK', 'Exists');
}
db.get(`SELECT name FROM sqlite_master WHERE type='table' AND name='reviews'`, [], (err, row) => {
if (err || !row) {
addResult('Table: reviews', 'FAIL', 'Not found');
} else {
addResult('Table: reviews', 'OK', 'Exists');
}
console.log(tableFooter);
const passed = results.filter(r => r.status === 'OK').length;
@@ -120,6 +152,8 @@ function runStartupTests(db, modules) {
});
});
});
});
});
} catch (error) {
addResult('Critical Error', 'FAIL', error.message.substring(0, 24));
console.log(tableFooter);