Отзывы
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
data
|
||||
/data/
|
||||
promt
|
||||
15
check_db.js
Normal file
15
check_db.js
Normal 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
8
check_settings.js
Normal 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();
|
||||
});
|
||||
276
modules/reviews/index.js
Normal file
276
modules/reviews/index.js
Normal 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
114
modules/settings/index.js
Normal 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 };
|
||||
217
modules/translations/index.js
Normal file
217
modules/translations/index.js
Normal 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 };
|
||||
@@ -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
575
public/css/reviews.css
Normal 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
22
public/data/cities/RU.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const CITIES_RU = [
|
||||
"Москва", "Санкт-Петербург", "Новосибирск", "Екатеринбург", "Казань",
|
||||
"Краснодар", "Красноярск", "Сочи", "Пермь", "Воронеж", "Волгоград",
|
||||
"Ростов-на-Дону", "Уфа", "Самара", "Омск", "Челябинск", "Нижний Новгород",
|
||||
"Минск", "Иркутск", "Хабаровск", "Барнаул", "Владивосток", "Тюмень",
|
||||
"Ижевск", "Томск", "Кемерово", "Оренбург", "Новокузнецк", "Тольятти",
|
||||
"Саратов", "Астрахань", "Набережные Челны", "Брянск", "Калининград",
|
||||
"Липецк", "Курск", "Сочи", "Ставрополь", "Белгород", "Владимир",
|
||||
"Архангельск", "Севастополь", "Симферополь", "Пенза", "Тула", "Ульяновск",
|
||||
"Ярославль", "Дзержинск", "Мурманск", "Череповец", "Волжский", "Сургут",
|
||||
"Смоленск", "Подольск", "Великий Новгород", "Чита", "Калуга", "Благовещенск",
|
||||
"Вологда", "Курган", "Сыктывкар", "Орёл", "Петрозаводск", "Йошкар-Ола",
|
||||
"Саранск", "Абакан", "Нальчик", "Элиста", "Черкесск", "Махачкала",
|
||||
"Грозный", "Ставрополь", "Владикавказ", "Ханты-Мансийск", "Анадырь",
|
||||
"Петропавловск-Камчатский", "Магадан", "Якутск", "Бийск", "Пятигорск",
|
||||
"Таганрог", "Новочеркасск", "Азов", "Шахты", "Балашиха", "Королёв",
|
||||
"Мытищи", "Люберцы", "Красногорск", "Электросталь", "Коломна", "Одинцово",
|
||||
"Железнодорожный", "Серпухов", "Пушкино", "Щёлково", "Ногинск", "Раменское",
|
||||
"Домодедово", "Подольск", "Химки", "Братск", "Ангарск", "Норильск",
|
||||
"Тверь", "Рязань", "Иваново", "Белгород", "Армавир", "Новороссийск",
|
||||
"Геленджик", "Анапа", "Ейск", "Туапсе", "Кисловодск", "Пятигорск"
|
||||
];
|
||||
116
public/data/cities/major.js
Normal file
116
public/data/cities/major.js
Normal 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
126
public/data/countries.js
Normal 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: 'Зимбабве' }
|
||||
];
|
||||
@@ -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,106 +336,36 @@
|
||||
</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>
|
||||
<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>
|
||||
</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="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()">×</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
110
public/js/i18n.js
Normal 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
550
public/js/reviews.js
Normal 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
133
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'));
|
||||
|
||||
23
test_api.js
Normal file
23
test_api.js
Normal 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();
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user