From b8d7f23ef5426d1694e43baf44fd36ccfb1e64e0 Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Sun, 10 May 2026 21:42:31 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=82=D0=B7=D1=8B=D0=B2=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- check_db.js | 15 + check_settings.js | 8 + hotel.db | 0 modules/reviews/index.js | 276 ++++++++++++++++ modules/settings/index.js | 114 +++++++ modules/translations/index.js | 217 +++++++++++++ public/admin.html | 191 +++++++++++ public/css/reviews.css | 575 ++++++++++++++++++++++++++++++++++ public/data/cities/RU.js | 22 ++ public/data/cities/major.js | 116 +++++++ public/data/countries.js | 126 ++++++++ public/index.html | 206 ++++++------ public/js/i18n.js | 110 +++++++ public/js/reviews.js | 550 ++++++++++++++++++++++++++++++++ server.js | 133 ++++++++ test_api.js | 23 ++ tests/runStartupTests.js | 50 ++- 18 files changed, 2632 insertions(+), 102 deletions(-) create mode 100644 check_db.js create mode 100644 check_settings.js create mode 100644 hotel.db create mode 100644 modules/reviews/index.js create mode 100644 modules/settings/index.js create mode 100644 modules/translations/index.js create mode 100644 public/css/reviews.css create mode 100644 public/data/cities/RU.js create mode 100644 public/data/cities/major.js create mode 100644 public/data/countries.js create mode 100644 public/js/i18n.js create mode 100644 public/js/reviews.js create mode 100644 test_api.js diff --git a/.gitignore b/.gitignore index 41925e8..91c8c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules package-lock.json .env -data +/data/ promt \ No newline at end of file diff --git a/check_db.js b/check_db.js new file mode 100644 index 0000000..f77da1e --- /dev/null +++ b/check_db.js @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/check_settings.js b/check_settings.js new file mode 100644 index 0000000..9b80eb8 --- /dev/null +++ b/check_settings.js @@ -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(); +}); \ No newline at end of file diff --git a/hotel.db b/hotel.db new file mode 100644 index 0000000..e69de29 diff --git a/modules/reviews/index.js b/modules/reviews/index.js new file mode 100644 index 0000000..df267f9 --- /dev/null +++ b/modules/reviews/index.js @@ -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 }; \ No newline at end of file diff --git a/modules/settings/index.js b/modules/settings/index.js new file mode 100644 index 0000000..1a145c9 --- /dev/null +++ b/modules/settings/index.js @@ -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 }; \ No newline at end of file diff --git a/modules/translations/index.js b/modules/translations/index.js new file mode 100644 index 0000000..e76ebce --- /dev/null +++ b/modules/translations/index.js @@ -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 }; \ No newline at end of file diff --git a/public/admin.html b/public/admin.html index ee130bc..2b08c41 100644 --- a/public/admin.html +++ b/public/admin.html @@ -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 # Пользователи Бронирования Промокоды + Отзывы + Настройки Профиль На сайт @@ -200,6 +218,11 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
Новых заявок
+
+
+
+
Отзывов на модерации
+

Последние бронирования

@@ -277,6 +300,62 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
+
+
+

Отзывы

+
+ +
+
+
+
+ + + +
АвторМестоположениеОценкаТекстСтатусДатаДействия
+
+
+
+ +
+
+

Настройки

+
+
+

Кодовое слово для отзывов

+
+

+ + Это кодовое слово гости должны вводить при оставлении отзыва. Сообщите его гостям на ресепшене. +

+
+
+
+ +
+ +
+
+
+
+ + +
+ +
+
+
+
+
+
+

Мой профиль

@@ -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 = 'Нет данных'; } + 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 = 'Нет отзывов'; + 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 '' + + '' + esc(r.author_name) + '' + + '' + location + '' + + '' + stars + ' ' + r.stars.toFixed(1) + '' + + '
' + esc(r.text).replace(/\n/g, '
') + '
' + + '' + statusText + '' + + '' + date + '' + + '' + + (r.is_approved !== 1 ? '' : '') + + (r.is_approved !== -1 && r.is_approved !== 0 ? '' : '') + + '' + + ''; + }).join(''); +} + +function renderStarsHTML(count) { + let html = ''; + const fullStars = Math.floor(count); + for (let i = 0; i < 5; i++) { + html += i < fullStars ? '' : ''; + } + 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 = '******'; + } +} diff --git a/public/css/reviews.css b/public/css/reviews.css new file mode 100644 index 0000000..f205554 --- /dev/null +++ b/public/css/reviews.css @@ -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; + } +} \ No newline at end of file diff --git a/public/data/cities/RU.js b/public/data/cities/RU.js new file mode 100644 index 0000000..dc5c0d2 --- /dev/null +++ b/public/data/cities/RU.js @@ -0,0 +1,22 @@ +const CITIES_RU = [ + "Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", "Казань", + "Краснодар", "Красноярск", "Сочи", "Пермь", "Воронеж", "Волгоград", + "Ростов-на-Дону", "Уфа", "Самара", "Омск", "Челябинск", "Нижний Новгород", + "Минск", "Иркутск", "Хабаровск", "Барнаул", "Владивосток", "Тюмень", + "Ижевск", "Томск", "Кемерово", "Оренбург", "Новокузнецк", "Тольятти", + "Саратов", "Астрахань", "Набережные Челны", "Брянск", "Калининград", + "Липецк", "Курск", "Сочи", "Ставрополь", "Белгород", "Владимир", + "Архангельск", "Севастополь", "Симферополь", "Пенза", "Тула", "Ульяновск", + "Ярославль", "Дзержинск", "Мурманск", "Череповец", "Волжский", "Сургут", + "Смоленск", "Подольск", "Великий Новгород", "Чита", "Калуга", "Благовещенск", + "Вологда", "Курган", "Сыктывкар", "Орёл", "Петрозаводск", "Йошкар-Ола", + "Саранск", "Абакан", "Нальчик", "Элиста", "Черкесск", "Махачкала", + "Грозный", "Ставрополь", "Владикавказ", "Ханты-Мансийск", "Анадырь", + "Петропавловск-Камчатский", "Магадан", "Якутск", "Бийск", "Пятигорск", + "Таганрог", "Новочеркасск", "Азов", "Шахты", "Балашиха", "Королёв", + "Мытищи", "Люберцы", "Красногорск", "Электросталь", "Коломна", "Одинцово", + "Железнодорожный", "Серпухов", "Пушкино", "Щёлково", "Ногинск", "Раменское", + "Домодедово", "Подольск", "Химки", "Братск", "Ангарск", "Норильск", + "Тверь", "Рязань", "Иваново", "Белгород", "Армавир", "Новороссийск", + "Геленджик", "Анапа", "Ейск", "Туапсе", "Кисловодск", "Пятигорск" +]; \ No newline at end of file diff --git a/public/data/cities/major.js b/public/data/cities/major.js new file mode 100644 index 0000000..74be01e --- /dev/null +++ b/public/data/cities/major.js @@ -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; +} \ No newline at end of file diff --git a/public/data/countries.js b/public/data/countries.js new file mode 100644 index 0000000..df9a784 --- /dev/null +++ b/public/data/countries.js @@ -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: 'Зимбабве' } +]; \ No newline at end of file diff --git a/public/index.html b/public/index.html index a060b5b..043c7a9 100644 --- a/public/index.html +++ b/public/index.html @@ -9,6 +9,7 @@ + @@ -35,6 +36,10 @@ +
@@ -331,106 +336,36 @@ -
+
-
Отзывы
-

Что говорят наши гости

+
Отзывы
+

Что говорят наши гости

-

Более 500 счастливых гостей рекомендуют Hotel 777

-
-
-
-
- -
-

«Невероятное место! Чистейшее море, уютные номера и потрясающая абхазская кухня в столовой. Рауф Алексеевич — настоящий хозяин, встретил как родного. Обязательно вернёмся!»

-
-
АК
-
-
Анна Козлова
-
Москва • Август 2025
-
-
-
-
-
-
-
- -
-

«Брали VIP-номер — две комнаты, всё идеально. Кондиционер, WiFi, горячая вода — всё работает. SUP-борды брали в аренду, плавали вдоль побережья. Рауф Алексеевич помог с экскурсиями. Незабываемо!»

-
-
ДМ
-
-
Дмитрий Морозов
-
Санкт-Петербург • Июль 2025
-
-
-
-
-
-
-
- -
-

«Эконом-номер превзошёл ожидания — чистый, свежий, с видом на сад. До пляжа 5 минут, кафе рядом. Абхазия — это любовь с первого взгляда. Спасибо Рауфу Алексеевичу и Hotel 777!»

-
-
ЕС
-
-
Елена Смирнова
-
Казань • Сентябрь 2025
-
-
-
-
-
-
-
- -
-

«Отдыхали семьёй в стандарте — две кровати, детям понравилось. Рауф Алексеевич — супер-хозяин! Накормил так, что мы захотели остаться навсегда. Природа вокруг — просто сказка. Рекомендую всем!»

-
-
ИП
-
-
Игорь Петров
-
Сочи • Июнь 2025
-
-
-
-
-
-
-
- -
-

«Второй раз приезжаем в Hotel 777 и снова в восторге! Мгудзырхуа — тихое, красивое село. Море чистое, Рауф Алексеевич — гостеприимный хозяин. Это лучшая гостиница в Гудаутском районе!»

-
-
МВ
-
-
Мария Волкова
-
Новосибирск • Май 2025
-
-
-
-
-
-
-
- -
-

«Катались на SUP-бордах каждый день! Море спокойное, вода прозрачная. Номер был просторный, кровать удобная. Рауф Алексеевич организовал нам экскурсии по Абхазии. Лучший отпуск в жизни!»

-
-
ОН
-
-
Олег Новиков
-
Екатеринбург • Август 2025
-
-
+ + + +
+ + + +
+ +
@@ -686,6 +621,91 @@ + + + + +
+
+
+

Написать отзыв

+ +
+
+
+
+
+ +
+ +
+
Выберите страну
+
+
+ +
+ +
+
Выберите или введите город
+
+
+ + + +
+ +
+
+
+
5.0
+
+ +
+
Поставьте оценку
+
+ +
+ + +
Имя должно содержать минимум 2 символа
+
+ +
+ + +
Отзыв должен содержать минимум 20 символов
+
+ +
+ +
+ + +
+ Код сообщается гостям на ресепшене отеля +
Неверный код гостиницы
+
+
+
+ +
+
+ diff --git a/public/js/i18n.js b/public/js/i18n.js new file mode 100644 index 0000000..0ae8f85 --- /dev/null +++ b/public/js/i18n.js @@ -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 += ''; + } else if (count >= i - 0.5) { + html += ''; + } else { + html += ''; + } + } + 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 += ''; + } else if (i === fullStars && hasHalf) { + html += ''; + } else { + html += ''; + } + } + return html; + } +}; + +window.I18n = I18n; \ No newline at end of file diff --git a/public/js/reviews.js b/public/js/reviews.js new file mode 100644 index 0000000..2708d31 --- /dev/null +++ b/public/js/reviews.js @@ -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 ` +
+
+
+
${initials}
+
+
${escapeHtml(review.author_name)}
+ ${location ? `
${escapeHtml(location)}
` : ''} +
+
+
${stars}
+
+

«${escapeHtml(review.text)}»

+ ${date ? `
${date}
` : ''} +
+ `; + }).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 = ''; + + 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 = ''; + citySelect.disabled = true; + otherCityGroup.style.display = 'none'; + return; + } + + citySelect.innerHTML = ''; + 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 += ''; + } else if (i === fullStars && hasHalf) { + html += ''; + } else { + html += ''; + } + } + 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 = ''; + 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 = 'Отправка...'; + } + + 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 = 'Отправить отзыв'; + } + } + }); +} + +function showReviewSuccess() { + const body = document.getElementById('reviewModalBody'); + if (!body) return; + + body.innerHTML = ` +
+ +

${I18n.t('validation.success').split('.')[0]}

+

${I18n.t('validation.success').split('.')[1] || ''}

+
+ `; + + 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(); + } +}); \ No newline at end of file diff --git a/server.js b/server.js index 8455a59..745aea0 100644 --- a/server.js +++ b/server.js @@ -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')); diff --git a/test_api.js b/test_api.js new file mode 100644 index 0000000..d9b1c16 --- /dev/null +++ b/test_api.js @@ -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(); \ No newline at end of file diff --git a/tests/runStartupTests.js b/tests/runStartupTests.js index b94b978..248cbec 100644 --- a/tests/runStartupTests.js +++ b/tests/runStartupTests.js @@ -103,17 +103,51 @@ function runStartupTests(db, modules) { addResult('Module: adminBookings', 'FAIL', 'Not loaded'); } - console.log(tableFooter); + if (modules.settings) { + addResult('Module: settings', 'OK', 'Initialized'); + } else { + addResult('Module: settings', 'FAIL', 'Not loaded'); + } - const passed = results.filter(r => r.status === 'OK').length; - const total = results.length; - const allPassed = passed === total; + if (modules.reviews) { + addResult('Module: reviews', 'OK', 'Initialized'); + } else { + addResult('Module: reviews', 'FAIL', 'Not loaded'); + } - console.log(`\nРезультат: ${passed}/${total} тестов пройдено`); - console.log(allPassed ? '✅ Все системы готовы к работе!' : '❌ Имеются проблемы - проверьте логи'); - console.log('========================================\n'); + if (modules.translations) { + addResult('Module: translations', 'OK', 'Initialized'); + } else { + addResult('Module: translations', 'FAIL', 'Not loaded'); + } - resolve({ results, passed, total, allPassed }); + 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; + const total = results.length; + const allPassed = passed === total; + + console.log(`\nРезультат: ${passed}/${total} тестов пройдено`); + console.log(allPassed ? '✅ Все системы готовы к работе!' : '❌ Имеются проблемы - проверьте логи'); + console.log('========================================\n'); + + resolve({ results, passed, total, allPassed }); + }); + }); }); }); });