Компактные hero секции и улучшенная инициализация БД

🎨 UI улучшения:
- Уменьшена высота синих панелей с 100vh до 70vh на главной
- Добавлен класс .compact (25vh) для всех остальных страниц
- Улучшена адаптивность для мобильных устройств
- Обновлены все шаблоны с hero секциями

🚀 Инфраструктура:
- Автоматическая инициализация базы данных при деплое
- Улучшены мокапные данные (больше отзывов, бронирований, сообщений)
- Добавлены настройки сайта в базу данных
- Создан скрипт автоматического деплоя deploy.sh

📦 Система сборки:
- Обновлен .gitignore с полным покрытием файлов
- Добавлена папка для загрузок с .gitkeep
- Улучшен README с инструкциями по запуску
- ES модули для инициализации базы данных

🐛 Исправления:
- Совместимость с ES модулями в Node.js
- Правильная обработка ошибок инициализации БД
- Корректные SQL запросы для PostgreSQL
This commit is contained in:
2025-11-29 18:47:42 +09:00
parent 409e6c146b
commit a461fea9d9
24 changed files with 1442 additions and 84 deletions

22
.gitignore vendored
View File

@@ -48,3 +48,25 @@ build/
tmp/
temp/
# Uploaded files
public/uploads/*
!public/uploads/.gitkeep
# Database backups
database/backups/*.sql
database/backups/*.dump
# Docker volumes
data/
postgres-data/
# Coverage and testing
coverage/
*.lcov
.nyc_output
# Runtime files
pids/
*.pid
*.seed
*.pid.lock

View File

@@ -7,6 +7,38 @@
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-13+-blue?style=for-the-badge)
![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=for-the-badge)
## 🚀 Быстрый старт
### Автоматический деплой
```bash
./deploy.sh
```
### Ручной запуск
```bash
# Клонирование репозитория
git clone <repository-url>
cd tourism_site
# Запуск с Docker
docker-compose up --build -d
# Или локальная разработка
npm install
npm run dev
```
## 📱 Доступ к приложению
- **Основной сайт**: http://localhost:3000
- **Админ панель**: http://localhost:3000/admin
- **База данных**: http://localhost:8080 (Adminer)
## 🔑 Учётные данные по умолчанию
- **Админ**: admin / admin123
- **База данных**: postgres / postgres
## 🌟 Особенности
### 🎯 Основные функции

77
database/init-database.js Normal file
View File

@@ -0,0 +1,77 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import db from '../src/config/database.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export async function initDatabase() {
try {
console.log('🚀 Starting complete database initialization...');
// Check if database is connected
await db.query('SELECT 1');
console.log('✅ Database connection successful');
// 1. Create schema
console.log('📋 Creating database schema...');
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
await db.query(schema);
console.log('✅ Database schema created successfully');
// 2. Check if tables are empty (first run)
const checkResult = await db.query('SELECT COUNT(*) FROM admins');
const isEmpty = parseInt(checkResult.rows[0].count) === 0;
if (isEmpty) {
console.log('📝 Database is empty, inserting mock data...');
// Insert mock data
const mockDataPath = path.join(__dirname, 'mock-data.sql');
const mockData = fs.readFileSync(mockDataPath, 'utf8');
await db.query(mockData);
console.log('✅ Mock data inserted successfully');
} else {
console.log(' Database already contains data, skipping mock data insertion');
}
// 3. Run any pending migrations
console.log('🔄 Checking for pending migrations...');
// Check if rating system exists
try {
await db.query('SELECT 1 FROM route_ratings LIMIT 1');
console.log(' Rating system already exists');
} catch (error) {
console.log('📈 Installing rating system...');
const ratingMigrationPath = path.join(__dirname, 'rating-system-migration.sql');
if (fs.existsSync(ratingMigrationPath)) {
const ratingMigration = fs.readFileSync(ratingMigrationPath, 'utf8');
await db.query(ratingMigration);
console.log('✅ Rating system installed successfully');
}
}
console.log('✨ Database initialization completed successfully!');
} catch (error) {
console.error('❌ Database initialization failed:', error);
throw error;
}
}
// Run if called directly
if (process.argv[1] === fileURLToPath(import.meta.url)) {
initDatabase()
.then(() => {
console.log('🎉 All done!');
process.exit(0);
})
.catch((error) => {
console.error('💥 Initialization failed:', error);
process.exit(1);
});
}

View File

@@ -80,24 +80,44 @@ INSERT INTO reviews (route_id, customer_name, customer_email, rating, comment, i
(7, 'Михаил Рыбаков', 'mikhail.rybakov@email.com', 4, 'Два дня рыбалки у берегов Пусана - это было здорово! Поймали много рыбы, капитан Ли очень опытный. Единственный минус - качка была сильная, но это природа. Улов приготовили прямо на борту - вкуснее не ел никогда!', true, NOW() - INTERVAL '8 days'),
(2, 'Елена Иванова', 'elena.ivanova@email.com', 5, 'Морской воздух, свежие морепродукты, красивые пляжи - всё было идеально! Гид Чой Ю На показала лучшие места для фото и рассказала много интересного о жизни в портовом городе.', true, NOW() - INTERVAL '15 days'),
(1, 'Сергей Морозов', 'sergey.morozov@email.com', 4, 'Тур по Сеулу организован очень хорошо. Группа небольшая, гид внимательный. Единственное - хотелось бы больше свободного времени для шопинга в Мёндоне. В целом очень доволен!', true, NOW() - INTERVAL '20 days'),
(6, 'Ольга Кузнецова', 'olga.kuznetsova@email.com', 5, 'Халласан покорился легко благодаря отличной подготовке группы. Остров Чеджу вообще сказочный - вулканические пляжи, мандариновые рощи, дружелюбные местные жители. Хочу вернуться!', true, NOW() - INTERVAL '25 days');
(6, 'Ольга Кузнецова', 'olga.kuznetsova@email.com', 5, 'Халласан покорился легко благодаря отличной подготовке группы. Остров Чеджу вообще сказочный - вулканические пляжи, мандариновые рощи, дружелюбные местные жители. Хочу вернуться!', true, NOW() - INTERVAL '25 days'),
(3, 'Виктор Николаев', 'viktor.nikolaev@email.com', 5, 'Кёнджу - это путешествие во времени! Юн Тэ Гу просто энциклопедия корейской истории. Каждый камень в Пульгуксе имеет свою историю. Грот Соккурам поражает мастерством древних зодчих.', true, NOW() - INTERVAL '30 days'),
(5, 'Татьяна Смирнова', 'tatyana.smirnova@email.com', 4, 'Чирисан - это духовное очищение. Ночёвки в храмах, медитации на рассвете, звенящая тишина гор. Физически тяжело, но душевно очень важно. Рекомендую всем, кто ищет смысл жизни.', true, NOW() - INTERVAL '35 days'),
(8, 'Алексей Рыбников', 'alexey.rybnikov@email.com', 5, 'Три дня на рыбацком судне в Восточном море - незабываемо! Ночная рыбалка на кальмаров, рассвет над морем, свежайшая рыба на завтрак. Капитан Ли и команда - настоящие профессионалы!', true, NOW() - INTERVAL '40 days');
-- Настройки сайта
INSERT INTO site_settings (setting_key, setting_value, description, updated_at) VALUES
('site_name', 'Корея Тур Агентство', 'Название сайта', NOW()),
('site_description', 'Откройте для себя красоту Кореи с нашими профессиональными турами', 'Описание сайта для SEO', NOW()),
('contact_email', 'info@koreatour.ru', 'Email для связи', NOW()),
('contact_phone', '+7 (495) 123-45-67', 'Телефон для связи', NOW()),
('contact_address', 'Москва, ул. Примерная, д. 123', 'Адрес офиса', NOW()),
('social_facebook', 'https://facebook.com/koreatour', 'Ссылка на Facebook', NOW()),
('social_instagram', 'https://instagram.com/koreatour', 'Ссылка на Instagram', NOW()),
('social_youtube', 'https://youtube.com/koreatour', 'Ссылка на YouTube', NOW()),
('booking_email', 'booking@koreatour.ru', 'Email для бронирования', NOW()),
('emergency_phone', '+82-10-911-1234', 'Экстренный телефон в Корее', NOW());
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, updated_at) VALUES
('site_name', 'Корея Тур Агентство', 'text', 'Название сайта', NOW()),
('site_description', 'Откройте для себя красоту Кореи с нашими профессиональными турами', 'text', 'Описание сайта для SEO', NOW()),
('contact_email', 'info@koreatour.ru', 'text', 'Email для связи', NOW()),
('contact_phone', '+7 (495) 123-45-67', 'text', 'Телефон для связи', NOW()),
('contact_address', 'Москва, ул. Примерная, д. 123', 'text', 'Адрес офиса', NOW()),
('social_facebook', 'https://facebook.com/koreatour', 'text', 'Ссылка на Facebook', NOW()),
('social_instagram', 'https://instagram.com/koreatour', 'text', 'Ссылка на Instagram', NOW()),
('social_youtube', 'https://youtube.com/koreatour', 'text', 'Ссылка на YouTube', NOW()),
('booking_email', 'booking@koreatour.ru', 'text', 'Email для бронирования', NOW()),
('emergency_phone', '+82-10-911-1234', 'text', 'Экстренный телефон в Корее', NOW()),
('booking_enabled', 'true', 'boolean', 'Включить онлайн бронирование', NOW()),
('maintenance_mode', 'false', 'boolean', 'Режим обслуживания', NOW()),
('max_group_size', '15', 'number', 'Максимальный размер группы', NOW()),
('default_currency', 'KRW', 'text', 'Валюта по умолчанию', NOW()),
('facebook_url', 'https://facebook.com/koreatour', 'text', 'URL Facebook страницы', NOW()),
('instagram_url', 'https://instagram.com/koreatour', 'text', 'URL Instagram профиля', NOW()),
('twitter_url', 'https://twitter.com/koreatour', 'text', 'URL Twitter профиля', NOW());
-- Примеры бронирований
INSERT INTO bookings (route_id, customer_name, customer_email, customer_phone, preferred_date, status, notes, created_at) VALUES
(1, 'Иван Петров', 'ivan.petrov@email.com', '+7-915-123-4567', '2024-04-15', 'confirmed', 'Оплата получена, гид назначен', NOW() - INTERVAL '3 days'),
INSERT INTO bookings (route_id, guide_id, customer_name, customer_email, customer_phone, preferred_date, group_size, total_price, status, notes, created_at) VALUES
(1, 1, 'Иван Петров', 'ivan.petrov@email.com', '+7-915-123-4567', '2025-12-15', 2, 900000, 'confirmed', 'Оплата получена, гид назначен', NOW() - INTERVAL '3 days'),
(4, 2, 'Мария Сидорова', 'maria.sidorova@email.com', '+7-916-234-5678', '2025-12-20', 4, 2720000, 'pending', 'Ожидает подтверждения оплаты', NOW() - INTERVAL '1 day'),
(7, 3, 'Андрей Козлов', 'andrey.kozlov@email.com', '+7-917-345-6789', '2025-12-25', 3, 1260000, 'confirmed', 'Группа из трёх друзей, все опытные рыбаки', NOW() - INTERVAL '5 days'),
(2, 4, 'Светлана Попова', 'svetlana.popova@email.com', '+7-918-456-7890', '2026-01-05', 1, 320000, 'pending', 'Первый раз в Корее, нужна подробная консультация', NOW() - INTERVAL '2 days');
-- Контактные сообщения
INSERT INTO contact_messages (name, email, phone, subject, message, status, created_at) VALUES
('Алексей Иванов', 'alexey.ivanov@email.com', '+7-921-123-4567', 'Вопрос о горных турах', 'Здравствуйте! Интересуют походы в горы для начинающих. Какой тур посоветуете для первого раза? Опыт походов минимальный, но физическая подготовка хорошая.', 'unread', NOW() - INTERVAL '2 hours'),
('Наталья Кузнецова', 'natasha.kuznetsova@email.com', '+7-925-234-5678', 'Групповой тур в Сеул', 'Планируем поездку компанией из 8 человек в апреле. Возможно ли организовать индивидуальный тур по дворцам Сеула? Интересуют цены и программа.', 'read', NOW() - INTERVAL '1 day'),
('Михаил Петров', 'mikhail.petrov@email.com', '+7-926-345-6789', 'Рыбалка для новичков', 'Никогда не рыбачил в море, но очень хочется попробовать. Есть ли туры для абсолютных новичков? Нужно ли своё снаряжение?', 'replied', NOW() - INTERVAL '3 days'),
('Елена Смирнова', 'elena.smirnova@email.com', '+7-927-456-7890', 'Сезонные особенности', 'Какое время года лучше для посещения храмов и дворцов? Планируем поездку с детьми 12 и 15 лет. Спасибо!', 'unread', NOW() - INTERVAL '5 hours');
(4, 'Мария Сидорова', 'maria.sidorova@email.com', '+7-926-234-5678', '2024-05-20', 'pending', 'Ожидаем подтверждение доступности адаптированного маршрута', NOW() - INTERVAL '1 day'),
(7, 'Алексей Рыбак', 'alexey.rybak@email.com', '+7-903-345-6789', '2024-03-25', 'confirmed', 'Забронировано судно для опытных рыболовов', NOW() - INTERVAL '7 days'),
(2, 'Екатерина Новикова', 'ekaterina.novikova@email.com', '+7-985-456-7890', '2024-04-08', 'completed', 'Тур завершён, клиент оставил отличный отзыв', NOW() - INTERVAL '14 days');

View File

@@ -0,0 +1,73 @@
-- Система лайков/дизлайков для туров, гидов и статей
CREATE TABLE ratings (
id SERIAL PRIMARY KEY,
user_ip VARCHAR(45) NOT NULL, -- IP адрес для анонимных пользователей
target_id INTEGER NOT NULL,
target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('route', 'guide', 'article')),
rating INTEGER NOT NULL CHECK (rating IN (1, -1)), -- 1 = лайк, -1 = дизлайк
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_ip, target_id, target_type) -- Один пользователь - один голос за объект
);
-- График работы гидов
CREATE TABLE guide_schedules (
id SERIAL PRIMARY KEY,
guide_id INTEGER NOT NULL REFERENCES guides(id) ON DELETE CASCADE,
monday BOOLEAN DEFAULT true,
tuesday BOOLEAN DEFAULT true,
wednesday BOOLEAN DEFAULT true,
thursday BOOLEAN DEFAULT true,
friday BOOLEAN DEFAULT true,
saturday BOOLEAN DEFAULT false,
sunday BOOLEAN DEFAULT false,
start_time TIME DEFAULT '09:00',
end_time TIME DEFAULT '18:00',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(guide_id)
);
-- Выходные дни (общие и индивидуальные)
CREATE TABLE holidays (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
title VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('public', 'guide_personal')), -- публичный или персональный
guide_id INTEGER REFERENCES guides(id) ON DELETE CASCADE, -- NULL для публичных выходных
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, guide_id) -- Один выходной день для одного гида
);
-- Обновляем таблицу бронирований для связи с гидом
ALTER TABLE bookings ADD COLUMN guide_id INTEGER REFERENCES guides(id);
ALTER TABLE bookings ADD COLUMN route_id INTEGER REFERENCES routes(id);
-- Добавляем индексы для производительности
CREATE INDEX idx_ratings_target ON ratings(target_type, target_id);
CREATE INDEX idx_ratings_user_ip ON ratings(user_ip);
CREATE INDEX idx_bookings_date ON bookings(preferred_date);
CREATE INDEX idx_bookings_guide ON bookings(guide_id);
CREATE INDEX idx_holidays_date ON holidays(date);
-- Функция для подсчета рейтинга
CREATE OR REPLACE FUNCTION calculate_rating(target_type_param VARCHAR, target_id_param INTEGER)
RETURNS TABLE(
likes_count BIGINT,
dislikes_count BIGINT,
total_votes BIGINT,
rating_percentage NUMERIC(5,2)
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(CASE WHEN rating = 1 THEN 1 END) as likes_count,
COUNT(CASE WHEN rating = -1 THEN 1 END) as dislikes_count,
COUNT(*) as total_votes,
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND((COUNT(CASE WHEN rating = 1 THEN 1 END)::NUMERIC / COUNT(*)::NUMERIC) * 100, 2)
END as rating_percentage
FROM ratings
WHERE target_type = target_type_param AND target_id = target_id_param;
END;
$$ LANGUAGE plpgsql;

106
database/run-migration.js Normal file
View File

@@ -0,0 +1,106 @@
import pool from '../src/config/database.js';
const migrateRatingSystem = async () => {
try {
console.log('🔄 Выполняю миграцию для системы рейтингов...');
// Создание таблицы ratings
await pool.query(`
CREATE TABLE IF NOT EXISTS ratings (
id SERIAL PRIMARY KEY,
user_ip VARCHAR(45) NOT NULL,
target_id INTEGER NOT NULL,
target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('route', 'guide', 'article')),
rating INTEGER NOT NULL CHECK (rating IN (1, -1)),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_ip, target_id, target_type)
);
`);
// Создание таблицы guide_schedules
await pool.query(`
CREATE TABLE IF NOT EXISTS guide_schedules (
id SERIAL PRIMARY KEY,
guide_id INTEGER NOT NULL REFERENCES guides(id) ON DELETE CASCADE,
monday BOOLEAN DEFAULT true,
tuesday BOOLEAN DEFAULT true,
wednesday BOOLEAN DEFAULT true,
thursday BOOLEAN DEFAULT true,
friday BOOLEAN DEFAULT true,
saturday BOOLEAN DEFAULT false,
sunday BOOLEAN DEFAULT false,
start_time TIME DEFAULT '09:00',
end_time TIME DEFAULT '18:00',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(guide_id)
);
`);
// Создание таблицы holidays
await pool.query(`
CREATE TABLE IF NOT EXISTS holidays (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
title VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('public', 'guide_personal')),
guide_id INTEGER REFERENCES guides(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, guide_id)
);
`);
// Добавление колонок в bookings (если не существуют)
try {
await pool.query('ALTER TABLE bookings ADD COLUMN guide_id INTEGER REFERENCES guides(id);');
} catch (e) {
console.log('Колонка guide_id уже существует');
}
try {
await pool.query('ALTER TABLE bookings ADD COLUMN route_id INTEGER REFERENCES routes(id);');
} catch (e) {
console.log('Колонка route_id уже существует');
}
// Создание индексов
await pool.query('CREATE INDEX IF NOT EXISTS idx_ratings_target ON ratings(target_type, target_id);');
await pool.query('CREATE INDEX IF NOT EXISTS idx_ratings_user_ip ON ratings(user_ip);');
await pool.query('CREATE INDEX IF NOT EXISTS idx_bookings_date ON bookings(preferred_date);');
await pool.query('CREATE INDEX IF NOT EXISTS idx_bookings_guide ON bookings(guide_id);');
await pool.query('CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays(date);');
// Функция для подсчета рейтинга
await pool.query(`
CREATE OR REPLACE FUNCTION calculate_rating(target_type_param VARCHAR, target_id_param INTEGER)
RETURNS TABLE(
likes_count BIGINT,
dislikes_count BIGINT,
total_votes BIGINT,
rating_percentage NUMERIC(5,2)
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(CASE WHEN rating = 1 THEN 1 END) as likes_count,
COUNT(CASE WHEN rating = -1 THEN 1 END) as dislikes_count,
COUNT(*) as total_votes,
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE ROUND((COUNT(CASE WHEN rating = 1 THEN 1 END)::NUMERIC / COUNT(*)::NUMERIC) * 100, 2)
END as rating_percentage
FROM ratings
WHERE target_type = target_type_param AND target_id = target_id_param;
END;
$$ LANGUAGE plpgsql;
`);
console.log('✅ Миграция выполнена успешно!');
process.exit(0);
} catch (error) {
console.error('❌ Ошибка миграции:', error);
process.exit(1);
}
};
migrateRatingSystem();

148
database/seed-guide-data.js Normal file
View File

@@ -0,0 +1,148 @@
import pool from '../src/config/database.js';
const seedGuideData = async () => {
try {
console.log('🌱 Добавляю тестовых гидов и их расписания...');
// Добавляем гидов, если их нет
const guidesCount = await pool.query('SELECT COUNT(*) FROM guides');
if (parseInt(guidesCount.rows[0].count) === 0) {
await pool.query(`
INSERT INTO guides (name, email, phone, languages, specialization, bio, experience, hourly_rate, is_active) VALUES
('Ли Мин Хо', 'lee@korea-tours.com', '+82-10-1234-5678', 'Корейский, Английский, Русский', 'city', 'Опытный гид по Сеулу с 8-летним стажем. Знаю все исторические места и современные достопримечательности столицы.', 8, 45000, true),
('Пак Со Ён', 'park@korea-tours.com', '+82-10-2345-6789', 'Корейский, Английский, Китайский', 'mountain', 'Профессиональный горный гид. Провожу походы в Сораксан, Чириксан и другие национальные парки Кореи.', 12, 55000, true),
('Ким Джун Су', 'kim@korea-tours.com', '+82-10-3456-7890', 'Корейский, Японский, Английский', 'fishing', 'Эксперт по рыбалке в морских и пресных водах. Организую туры на лучшие рыболовные места Кореи.', 15, 60000, true),
('Чой Хе Ран', 'choi@korea-tours.com', '+82-10-4567-8901', 'Корейский, Английский, Русский', 'general', 'Универсальный гид с широким спектром знаний о корейской культуре, истории и природе.', 6, 40000, true),
('Юн Тэ Хён', 'yoon@korea-tours.com', '+82-10-5678-9012', 'Корейский, Английский', 'city', 'Специалист по культурным турам и K-pop маршрутам. Покажу вам современную Корею глазами молодежи.', 4, 38000, true)
`);
}
// Получаем ID гидов
const guides = await pool.query('SELECT id FROM guides ORDER BY id');
// Добавляем расписания для каждого гида
for (const guide of guides.rows) {
const existingSchedule = await pool.query('SELECT * FROM guide_schedules WHERE guide_id = $1', [guide.id]);
if (existingSchedule.rows.length === 0) {
// Разные расписания для разных гидов
const schedules = [
{ monday: true, tuesday: true, wednesday: true, thursday: true, friday: true, saturday: true, sunday: false }, // Работает 6 дней
{ monday: true, tuesday: false, wednesday: true, thursday: true, friday: true, saturday: true, sunday: true }, // Выходной вторник
{ monday: true, tuesday: true, wednesday: true, thursday: true, friday: true, saturday: false, sunday: false }, // Только будни
{ monday: true, tuesday: true, wednesday: false, thursday: true, friday: true, saturday: true, sunday: true }, // Выходной среда
{ monday: false, tuesday: true, wednesday: true, thursday: true, friday: true, saturday: true, sunday: true } // Выходной понедельник
];
const schedule = schedules[(guide.id - 1) % schedules.length];
await pool.query(`
INSERT INTO guide_schedules (guide_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, start_time, end_time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '09:00', '18:00')
`, [
guide.id,
schedule.monday,
schedule.tuesday,
schedule.wednesday,
schedule.thursday,
schedule.friday,
schedule.saturday,
schedule.sunday
]);
}
}
// Добавляем несколько общих выходных дней
const holidays = [
{ date: '2025-01-01', title: 'Новый год', type: 'public' },
{ date: '2025-02-10', title: 'Лунный Новый год', type: 'public' },
{ date: '2025-03-01', title: 'День независимости', type: 'public' },
{ date: '2025-05-05', title: 'День детей', type: 'public' },
{ date: '2025-05-15', title: 'День Будды', type: 'public' },
{ date: '2025-06-06', title: 'День памяти', type: 'public' },
{ date: '2025-08-15', title: 'День освобождения', type: 'public' },
{ date: '2025-10-03', title: 'День основания государства', type: 'public' },
{ date: '2025-10-09', title: 'День корейского алфавита', type: 'public' },
{ date: '2025-12-25', title: 'Рождество', type: 'public' }
];
for (const holiday of holidays) {
const existing = await pool.query('SELECT * FROM holidays WHERE date = $1 AND type = $2', [holiday.date, holiday.type]);
if (existing.rows.length === 0) {
await pool.query(
'INSERT INTO holidays (date, title, type) VALUES ($1, $2, $3)',
[holiday.date, holiday.title, holiday.type]
);
}
}
// Добавляем несколько индивидуальных выходных
if (guides.rows.length > 0) {
const personalHolidays = [
{ date: '2025-12-01', title: 'Личный отпуск', guide_id: guides.rows[0].id },
{ date: '2025-12-15', title: 'Семейное мероприятие', guide_id: guides.rows[1].id },
{ date: '2025-11-30', title: 'Медицинский осмотр', guide_id: guides.rows[2].id }
];
for (const holiday of personalHolidays) {
const existing = await pool.query('SELECT * FROM holidays WHERE date = $1 AND guide_id = $2', [holiday.date, holiday.guide_id]);
if (existing.rows.length === 0) {
await pool.query(
'INSERT INTO holidays (date, title, type, guide_id) VALUES ($1, $2, $3, $4)',
[holiday.date, holiday.title, 'guide_personal', holiday.guide_id]
);
}
}
}
// Добавляем тестовые рейтинги
const routes = await pool.query('SELECT id FROM routes LIMIT 3');
const articles = await pool.query('SELECT id FROM articles LIMIT 3');
const testRatings = [
// Рейтинги для гидов
...guides.rows.slice(0, 3).map((guide, index) => ({
target_type: 'guide',
target_id: guide.id,
ratings: index === 0 ? [1, 1, 1, 1, -1] : index === 1 ? [1, 1, -1] : [1, 1, 1, 1, 1, 1, -1, -1]
})),
// Рейтинги для маршрутов
...routes.rows.map((route, index) => ({
target_type: 'route',
target_id: route.id,
ratings: index === 0 ? [1, 1, 1] : index === 1 ? [1, -1, 1, 1] : [1, 1, 1, 1, 1]
})),
// Рейтинги для статей
...articles.rows.map((article, index) => ({
target_type: 'article',
target_id: article.id,
ratings: index === 0 ? [1, 1] : index === 1 ? [1, 1, 1, -1] : [1]
}))
];
for (const item of testRatings) {
for (let i = 0; i < item.ratings.length; i++) {
const userIp = `192.168.1.${100 + i}`;
const existing = await pool.query(
'SELECT * FROM ratings WHERE user_ip = $1 AND target_type = $2 AND target_id = $3',
[userIp, item.target_type, item.target_id]
);
if (existing.rows.length === 0) {
await pool.query(
'INSERT INTO ratings (user_ip, target_type, target_id, rating) VALUES ($1, $2, $3, $4)',
[userIp, item.target_type, item.target_id, item.ratings[i]]
);
}
}
}
console.log('✅ Тестовые данные добавлены успешно!');
process.exit(0);
} catch (error) {
console.error('❌ Ошибка добавления тестовых данных:', error);
process.exit(1);
}
};
seedGuideData();

70
deploy.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Korea Tourism Site Deployment Script
# Автоматический деплой с инициализацией базы данных
echo "🚀 Starting Korea Tourism Site deployment..."
# Проверяем наличие Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker first."
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
echo "✅ Docker and Docker Compose found"
# Останавливаем существующие контейнеры
echo "🛑 Stopping existing containers..."
docker-compose down
# Удаляем старые образы для принудительной пересборки
echo "🧹 Cleaning up old images..."
docker-compose down --rmi local 2>/dev/null || true
# Пересборка и запуск
echo "🏗️ Building and starting containers..."
docker-compose up --build -d
# Ждём запуска контейнеров
echo "⏱️ Waiting for containers to start..."
sleep 10
# Проверяем статус контейнеров
echo "📊 Container status:"
docker-compose ps
# Проверяем логи приложения
echo "📝 Application logs (last 20 lines):"
docker logs korea_tourism_app --tail 20
# Проверяем доступность приложения
echo "🔍 Testing application availability..."
if curl -f -s http://localhost:3000 > /dev/null; then
echo "✅ Application is running successfully at http://localhost:3000"
else
echo "⚠️ Application may not be fully ready yet. Check logs with:"
echo " docker logs korea_tourism_app -f"
fi
echo ""
echo "🎉 Deployment completed!"
echo ""
echo "📱 Application URLs:"
echo " Main site: http://localhost:3000"
echo " Admin panel: http://localhost:3000/admin"
echo " Database admin: http://localhost:8080 (Adminer)"
echo ""
echo "🔑 Default credentials:"
echo " Admin login: admin / admin123"
echo " Database: postgres / postgres"
echo ""
echo "🛠️ Useful commands:"
echo " View logs: docker logs korea_tourism_app -f"
echo " Stop services: docker-compose down"
echo " Restart: docker-compose restart"
echo ""

View File

@@ -111,11 +111,30 @@ body {
/* Hero Section */
.hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
min-height: 100vh;
min-height: 70vh;
position: relative;
overflow: hidden;
}
/* Compact Hero Section for other pages */
.hero-section.compact {
min-height: 25vh;
padding: 3rem 0;
}
/* Mobile optimization for hero sections */
@media (max-width: 768px) {
.hero-section {
min-height: 50vh;
padding: 2rem 0;
}
.hero-section.compact {
min-height: 20vh;
padding: 2rem 0;
}
}
.hero-background {
position: absolute;
top: 0;
@@ -463,7 +482,13 @@ footer a:hover {
.hero-section {
background: white;
color: black;
min-height: auto;
min-height: 20vh;
padding: 2rem 0;
}
.hero-section.compact {
min-height: 15vh;
padding: 1.5rem 0;
}
}

2
public/uploads/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Этот файл обеспечивает создание папки uploads в репозитории
# Папка нужна для загрузки изображений пользователей

View File

@@ -23,6 +23,17 @@ const app = express();
const PORT = process.env.PORT || 3000;
async function setupApp() {
// Initialize database on startup
try {
console.log('🚀 Initializing database...');
const { initDatabase } = await import('../database/init-database.js');
await initDatabase();
console.log('✅ Database initialized successfully');
} catch (error) {
console.error('❌ Database initialization failed:', error);
console.log('⚠️ Continuing without database initialization...');
}
// Security middleware
app.use(helmet({
@@ -137,12 +148,14 @@ const toursRouter = (await import('./routes/tours.js')).default;
const guidesRouter = (await import('./routes/guides.js')).default;
const articlesRouter = (await import('./routes/articles.js')).default;
const apiRouter = (await import('./routes/api.js')).default;
const ratingsRouter = (await import('./routes/ratings.js')).default;
app.use('/', indexRouter);
app.use('/routes', toursRouter);
app.use('/guides', guidesRouter);
app.use('/articles', articlesRouter);
app.use('/api', apiRouter);
app.use('/api', ratingsRouter);
// Health check endpoint
app.get('/health', (req, res) => {

View File

@@ -93,6 +93,8 @@ const Bookings = sequelize.define('bookings', {
status: { type: DataTypes.ENUM('pending', 'confirmed', 'cancelled', 'completed'), defaultValue: 'pending' },
total_price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
notes: { type: DataTypes.TEXT },
guide_id: { type: DataTypes.INTEGER },
route_id: { type: DataTypes.INTEGER },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
@@ -141,6 +143,75 @@ const Admins = sequelize.define('admins', {
tableName: 'admins'
});
// Новые модели для системы рейтинга и расписания
const Ratings = sequelize.define('ratings', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
user_ip: { type: DataTypes.STRING(45), allowNull: false },
target_id: { type: DataTypes.INTEGER, allowNull: false },
target_type: { type: DataTypes.ENUM('route', 'guide', 'article'), allowNull: false },
rating: { type: DataTypes.INTEGER, allowNull: false, validate: { isIn: [[1, -1]] } },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'ratings'
});
const GuideSchedules = sequelize.define('guide_schedules', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
guide_id: { type: DataTypes.INTEGER, allowNull: false },
monday: { type: DataTypes.BOOLEAN, defaultValue: true },
tuesday: { type: DataTypes.BOOLEAN, defaultValue: true },
wednesday: { type: DataTypes.BOOLEAN, defaultValue: true },
thursday: { type: DataTypes.BOOLEAN, defaultValue: true },
friday: { type: DataTypes.BOOLEAN, defaultValue: true },
saturday: { type: DataTypes.BOOLEAN, defaultValue: false },
sunday: { type: DataTypes.BOOLEAN, defaultValue: false },
start_time: { type: DataTypes.TIME, defaultValue: '09:00' },
end_time: { type: DataTypes.TIME, defaultValue: '18:00' },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'guide_schedules'
});
const Holidays = sequelize.define('holidays', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
date: { type: DataTypes.DATEONLY, allowNull: false },
title: { type: DataTypes.STRING, allowNull: false },
type: { type: DataTypes.ENUM('public', 'guide_personal'), allowNull: false },
guide_id: { type: DataTypes.INTEGER },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'holidays'
});
// Определение связей между моделями
Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' });
GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
Guides.hasMany(Holidays, { foreignKey: 'guide_id' });
Holidays.belongsTo(Guides, { foreignKey: 'guide_id' });
Guides.hasMany(Bookings, { foreignKey: 'guide_id' });
Bookings.belongsTo(Guides, { foreignKey: 'guide_id' });
Routes.hasMany(Bookings, { foreignKey: 'route_id' });
Bookings.belongsTo(Routes, { foreignKey: 'route_id' });
// Методы для получения рейтингов
const getRatingStats = async (targetType, targetId) => {
const result = await sequelize.query(
'SELECT * FROM calculate_rating(:targetType, :targetId)',
{
replacements: { targetType, targetId },
type: sequelize.QueryTypes.SELECT
}
);
return result[0] || { likes_count: 0, dislikes_count: 0, total_votes: 0, rating_percentage: 0 };
};
// Конфигурация AdminJS с ресурсами базы данных
// Конфигурация AdminJS с ресурсами Sequelize
@@ -457,6 +528,111 @@ const adminJsOptions = {
}
},
}
},
{
resource: Ratings,
options: {
parent: { name: 'Система рейтингов', icon: 'Star' },
listProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
showProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
filterProperties: ['target_type', 'target_id', 'rating'],
properties: {
target_type: {
availableValues: [
{ value: 'route', label: 'Маршрут' },
{ value: 'guide', label: 'Гид' },
{ value: 'article', label: 'Статья' }
],
},
rating: {
availableValues: [
{ value: 1, label: '👍 Лайк' },
{ value: -1, label: '👎 Дизлайк' }
],
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
actions: {
new: { isAccessible: false },
edit: { isAccessible: false },
delete: { isAccessible: true },
list: { isAccessible: true },
show: { isAccessible: true }
}
}
},
{
resource: GuideSchedules,
options: {
parent: { name: 'Управление гидами', icon: 'Calendar' },
listProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
editProperties: ['guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
showProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time', 'created_at', 'updated_at'],
filterProperties: ['guide_id'],
properties: {
guide_id: {
isTitle: true,
isRequired: true,
},
monday: { type: 'boolean' },
tuesday: { type: 'boolean' },
wednesday: { type: 'boolean' },
thursday: { type: 'boolean' },
friday: { type: 'boolean' },
saturday: { type: 'boolean' },
sunday: { type: 'boolean' },
start_time: {
type: 'string',
description: 'Время начала работы (формат HH:MM)'
},
end_time: {
type: 'string',
description: 'Время окончания работы (формат HH:MM)'
},
created_at: {
isVisible: { list: false, filter: false, show: true, edit: false },
},
updated_at: {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
}
},
{
resource: Holidays,
options: {
parent: { name: 'Управление гидами', icon: 'Calendar' },
listProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
editProperties: ['date', 'title', 'type', 'guide_id'],
showProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
filterProperties: ['date', 'type', 'guide_id'],
properties: {
date: {
isTitle: true,
isRequired: true,
type: 'date'
},
title: {
isRequired: true,
description: 'Название выходного дня'
},
type: {
availableValues: [
{ value: 'public', label: 'Общий выходной' },
{ value: 'guide_personal', label: 'Личный выходной гида' }
],
isRequired: true
},
guide_id: {
description: 'Оставить пустым для общих выходных'
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
}
],
rootPath: '/admin',

View File

@@ -8,11 +8,18 @@ router.get('/', async (req, res) => {
const { specialization, language, sort } = req.query;
let query = `
SELECT g.*,
COUNT(r.id) as route_count,
AVG(rv.rating) as avg_rating
COUNT(DISTINCT b.id) as booking_count,
AVG(rv.rating) as avg_rating,
COUNT(CASE WHEN rt.rating = 1 THEN 1 END) as likes,
COUNT(CASE WHEN rt.rating = -1 THEN 1 END) as dislikes,
CASE
WHEN COUNT(rt.rating) = 0 THEN 0
ELSE ROUND((COUNT(CASE WHEN rt.rating = 1 THEN 1 END)::NUMERIC / COUNT(rt.rating)::NUMERIC) * 100, 2)
END as rating_percentage
FROM guides g
LEFT JOIN routes r ON g.id = r.guide_id AND r.is_active = true
LEFT JOIN reviews rv ON g.id = rv.guide_id
LEFT JOIN bookings b ON g.id = b.guide_id AND b.status = 'completed'
LEFT JOIN reviews rv ON g.id = rv.guide_id AND rv.is_approved = true
LEFT JOIN ratings rt ON g.id = rt.target_id AND rt.target_type = 'guide'
WHERE g.is_active = true
`;
const params = [];

178
src/routes/ratings.js Normal file
View File

@@ -0,0 +1,178 @@
import express from 'express';
import pool from '../config/database.js';
const router = express.Router();
// Получить рейтинг объекта
router.get('/rating/:type/:id', async (req, res) => {
try {
const { type, id } = req.params;
if (!['route', 'guide', 'article'].includes(type)) {
return res.status(400).json({ error: 'Недопустимый тип объекта' });
}
const result = await pool.query(
'SELECT * FROM calculate_rating($1, $2)',
[type, parseInt(id)]
);
const rating = result.rows[0] || {
likes_count: 0,
dislikes_count: 0,
total_votes: 0,
rating_percentage: 0
};
res.json(rating);
} catch (error) {
console.error('Ошибка получения рейтинга:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Поставить лайк/дизлайк
router.post('/rating/:type/:id', async (req, res) => {
try {
const { type, id } = req.params;
const { rating } = req.body; // 1 для лайка, -1 для дизлайка
const userIp = req.ip || req.connection.remoteAddress;
if (!['route', 'guide', 'article'].includes(type)) {
return res.status(400).json({ error: 'Недопустимый тип объекта' });
}
if (![1, -1].includes(parseInt(rating))) {
return res.status(400).json({ error: 'Рейтинг должен быть 1 или -1' });
}
// Проверяем, голосовал ли уже этот пользователь
const existingVote = await pool.query(
'SELECT * FROM ratings WHERE user_ip = $1 AND target_type = $2 AND target_id = $3',
[userIp, type, parseInt(id)]
);
if (existingVote.rows.length > 0) {
// Обновляем существующий голос
await pool.query(
'UPDATE ratings SET rating = $1 WHERE user_ip = $2 AND target_type = $3 AND target_id = $4',
[parseInt(rating), userIp, type, parseInt(id)]
);
} else {
// Создаем новый голос
await pool.query(
'INSERT INTO ratings (user_ip, target_type, target_id, rating) VALUES ($1, $2, $3, $4)',
[userIp, type, parseInt(id), parseInt(rating)]
);
}
// Возвращаем обновленный рейтинг
const result = await pool.query(
'SELECT * FROM calculate_rating($1, $2)',
[type, parseInt(id)]
);
const updatedRating = result.rows[0];
res.json({
success: true,
message: rating === 1 ? 'Лайк поставлен!' : 'Дизлайк поставлен!',
percentage: updatedRating.rating_percentage || 0,
likes: updatedRating.likes_count || 0,
dislikes: updatedRating.dislikes_count || 0,
total: updatedRating.total_votes || 0
});
} catch (error) {
console.error('Ошибка установки рейтинга:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Получить расписание гида
router.get('/guide/:id/schedule', async (req, res) => {
try {
const { id } = req.params;
const schedule = await pool.query(
'SELECT * FROM guide_schedules WHERE guide_id = $1',
[parseInt(id)]
);
if (schedule.rows.length === 0) {
return res.json({
guide_id: parseInt(id),
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false,
start_time: '09:00',
end_time: '18:00'
});
}
res.json(schedule.rows[0]);
} catch (error) {
console.error('Ошибка получения расписания:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
// Проверить доступность гида на дату
router.get('/guide/:id/availability/:date', async (req, res) => {
try {
const { id, date } = req.params;
const targetDate = new Date(date);
const dayOfWeek = targetDate.getDay(); // 0 = воскресенье, 1 = понедельник, ...
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const dayName = dayNames[dayOfWeek];
// Проверяем расписание гида
const schedule = await pool.query(
`SELECT ${dayName} as works_today FROM guide_schedules WHERE guide_id = $1`,
[parseInt(id)]
);
let worksToday = true; // По умолчанию работает
if (schedule.rows.length > 0) {
worksToday = schedule.rows[0].works_today;
}
// Проверяем выходные дни
const holidays = await pool.query(
`SELECT * FROM holidays
WHERE date = $1 AND (type = 'public' OR (type = 'guide_personal' AND guide_id = $2))`,
[date, parseInt(id)]
);
const isHoliday = holidays.rows.length > 0;
// Проверяем существующие бронирования
const bookings = await pool.query(
'SELECT * FROM bookings WHERE guide_id = $1 AND preferred_date = $2 AND status != $3',
[parseInt(id), date, 'cancelled']
);
const isBooked = bookings.rows.length > 0;
const available = worksToday && !isHoliday && !isBooked;
res.json({
available,
worksToday,
isHoliday: isHoliday ? holidays.rows[0].title : null,
isBooked,
reason: available ? 'Доступен' :
!worksToday ? 'Не рабочий день' :
isHoliday ? `Выходной: ${holidays.rows[0].title}` :
isBooked ? 'Уже забронирован' : 'Недоступен'
});
} catch (error) {
console.error('Ошибка проверки доступности:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
export default router;

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white text-center py-5">
<section class="hero-section compact bg-primary text-white text-center py-5">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto">

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white py-5">
<section class="hero-section compact bg-primary text-white py-5">
<div class="container">
<div class="row">
<div class="col-lg-8">

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white text-center py-5">
<section class="hero-section compact bg-primary text-white text-center py-5">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto">

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white text-center py-5">
<section class="hero-section compact bg-primary text-white text-center py-5">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto">

View File

@@ -0,0 +1,316 @@
<!-- Hero Section -->
<section class="hero-section compact bg-primary text-white text-center py-5">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto">
<h1 class="display-4 fw-bold mb-4">Наши гиды</h1>
<p class="lead">Профессиональные и опытные гиды сделают ваше путешествие незабываемым</p>
</div>
</div>
</div>
</section>
<!-- Guides Grid -->
<section class="py-5">
<div class="container">
<% if (guides.length === 0) { %>
<div class="text-center py-5">
<i class="fas fa-user-tie text-muted" style="font-size: 4rem;"></i>
<h3 class="mt-3 text-muted">Гиды не найдены</h3>
<p class="text-muted">В данный момент нет доступных гидов.</p>
</div>
<% } else { %>
<div class="row">
<% guides.forEach(guide => { %>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 shadow-sm guide-card" data-guide-id="<%= guide.id %>" style="cursor: pointer; transition: transform 0.2s;">
<% if (guide.image_url && guide.image_url.trim()) { %>
<img src="<%= guide.image_url %>" class="card-img-top" alt="<%= guide.name %>" style="height: 250px; object-fit: cover;">
<% } else { %>
<img src="/images/placeholders/default-guide.svg" class="card-img-top" alt="<%= guide.name %>" style="height: 250px; object-fit: cover;">
<% } %>
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0"><%= guide.name %></h5>
<div class="rating-display" data-target-type="guide" data-target-id="<%= guide.id %>">
<div class="d-flex align-items-center">
<span class="rating-percentage text-warning fw-bold">
<% if (guide.rating_percentage > 0) { %>
<%= parseFloat(guide.rating_percentage).toFixed(0) %>%
<% } else { %>
0%
<% } %>
</span>
<small class="text-muted ms-1">
(<%= guide.likes || 0 %>/<%= (parseInt(guide.likes || 0) + parseInt(guide.dislikes || 0)) %>)
</small>
</div>
<div class="rating-buttons mt-1">
<button class="btn btn-sm btn-outline-success like-btn" data-rating="1">
<i class="fas fa-thumbs-up"></i> <span class="like-count"><%= guide.likes || 0 %></span>
</button>
<button class="btn btn-sm btn-outline-danger dislike-btn ms-1" data-rating="-1">
<i class="fas fa-thumbs-down"></i> <span class="dislike-count"><%= guide.dislikes || 0 %></span>
</button>
</div>
</div>
</div>
<div class="mb-3">
<% if (guide.specialization === 'city') { %>
<span class="badge bg-info">
<i class="fas fa-city me-1"></i>Городские туры
</span>
<% } else if (guide.specialization === 'mountain') { %>
<span class="badge bg-success">
<i class="fas fa-mountain me-1"></i>Горные походы
</span>
<% } else if (guide.specialization === 'fishing') { %>
<span class="badge bg-primary">
<i class="fas fa-fish me-1"></i>Рыбалка
</span>
<% } else { %>
<span class="badge bg-secondary">
<i class="fas fa-star me-1"></i>Универсальный
</span>
<% } %>
<% if (guide.experience > 0) { %>
<span class="badge bg-warning text-dark ms-1">
<i class="fas fa-medal me-1"></i><%= guide.experience %> лет опыта
</span>
<% } %>
</div>
<% if (guide.bio) { %>
<p class="card-text text-muted flex-grow-1"><%= guide.bio.length > 100 ? guide.bio.substring(0, 100) + '...' : guide.bio %></p>
<% } %>
<div class="mt-auto">
<% if (guide.languages) { %>
<div class="mb-2">
<small class="text-muted">
<i class="fas fa-language me-1"></i>
<%= guide.languages %>
</small>
</div>
<% } %>
<% if (guide.avg_rating) { %>
<div class="mb-2">
<i class="fas fa-star text-warning"></i>
<i class="fas fa-star text-warning"></i>
<i class="fas fa-star text-warning"></i>
<i class="fas fa-star text-warning"></i>
<i class="far fa-star text-muted"></i>
<small class="text-muted ms-2">(<%= parseFloat(guide.avg_rating).toFixed(1) %>)</small>
</div>
<% } %>
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small">
<i class="fas fa-calendar-check me-1"></i>
<%= guide.booking_count || 0 %> туров проведено
</div>
<% if (guide.hourly_rate) { %>
<div class="fw-bold text-primary">
₩<%= parseInt(guide.hourly_rate || 0).toLocaleString('ko-KR') %>/час
</div>
<% } %>
</div>
</div>
</div>
</div>
</div>
<% }); %>
</div>
<% } %>
</div>
</section>
<!-- Guide Detail Modal -->
<div class="modal fade" id="guideModal" tabindex="-1" aria-labelledby="guideModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="guideModalLabel">Информация о гиде</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="guideModalBody">
<!-- Динамически загружаемая информация о гиде -->
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<button type="button" class="btn btn-primary" id="bookGuideBtn">
<i class="fas fa-calendar-plus me-2"></i>Забронировать
</button>
</div>
</div>
</div>
</div>
<style>
.guide-card:hover {
transform: translateY(-5px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
}
.rating-buttons .btn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.rating-percentage {
font-size: 1.1rem;
}
.rating-buttons .btn.active {
opacity: 1;
}
.rating-buttons .btn:not(.active) {
opacity: 0.6;
}
</style>
<script>
$(document).ready(function() {
// Обработчик клика по карточке гида
$('.guide-card').on('click', function(e) {
// Не открываем модальное окно при клике на кнопки рейтинга
if ($(e.target).closest('.rating-buttons').length > 0) {
return;
}
const guideId = $(this).data('guide-id');
loadGuideDetails(guideId);
$('#guideModal').modal('show');
});
// Обработчики для кнопок лайк/дизлайк
$('.like-btn, .dislike-btn').on('click', function(e) {
e.stopPropagation();
const $container = $(this).closest('.rating-display');
const targetType = $container.data('target-type');
const targetId = $container.data('target-id');
const rating = parseInt($(this).data('rating'));
setRating(targetType, targetId, rating, $container);
});
});
function loadGuideDetails(guideId) {
$('#guideModalBody').html(`
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
`);
$.get(`/guides/${guideId}`)
.done(function(html) {
$('#guideModalBody').html(html);
$('#bookGuideBtn').data('guide-id', guideId);
})
.fail(function() {
$('#guideModalBody').html(`
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle fa-2x mb-3"></i>
<p>Ошибка загрузки информации о гиде</p>
</div>
`);
});
}
function setRating(targetType, targetId, rating, $container) {
$.post(`/api/rating/${targetType}/${targetId}`, { rating: rating })
.done(function(data) {
if (data.success) {
// Обновляем отображение рейтинга
updateRatingDisplay($container, data.rating);
// Показываем уведомление
showToast(data.message, 'success');
}
})
.fail(function(xhr) {
const error = xhr.responseJSON?.error || 'Ошибка при установке рейтинга';
showToast(error, 'error');
});
}
function updateRatingDisplay($container, ratingData) {
const percentage = parseFloat(ratingData.rating_percentage || 0);
const likes = parseInt(ratingData.likes_count || 0);
const dislikes = parseInt(ratingData.dislikes_count || 0);
const total = likes + dislikes;
$container.find('.rating-percentage').text(percentage.toFixed(0) + '%');
$container.find('.like-count').text(likes);
$container.find('.dislike-count').text(dislikes);
$container.find('small.text-muted').text(`(${likes}/${total})`);
}
function showToast(message, type) {
const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
const toast = $(`
<div class="toast align-items-center text-white ${bgClass} border-0" role="alert"
style="position: fixed; top: 20px; right: 20px; z-index: 9999;">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`);
$('body').append(toast);
new bootstrap.Toast(toast[0]).show();
// Удаляем toast после скрытия
toast.on('hidden.bs.toast', function() {
$(this).remove();
});
}
</script>
<small class="text-muted">
<i class="fas fa-calendar me-1"></i><%= guide.experience %> лет опыта
</small>
</div>
<div>
<small class="text-muted">
<i class="fas fa-route me-1"></i><%= guide.route_count || 0 %> туров
</small>
</div>
</div>
<a href="/guides/<%= guide.id %>" class="btn btn-primary w-100">
<i class="fas fa-user me-1"></i>Подробнее
</a>
</div>
</div>
</div>
</div>
<% }); %>
</div>
<% } %>
</div>
</section>
<!-- CTA Section -->
<section class="bg-light py-5">
<div class="container text-center">
<h2 class="mb-4">Нужен индивидуальный подход?</h2>
<p class="lead text-muted mb-4">Наши гиды готовы создать уникальный маршрут специально для вас!</p>
<a href="/contact" class="btn btn-primary btn-lg">
<i class="fas fa-envelope me-1"></i>Связаться с нами
</a>
</div>
</section>

View File

@@ -1,12 +1,8 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white text-center py-5">
<div class="container">
<div class="row">
<div class="col-lg-8 mx-auto">
<h1 class="display-4 fw-bold mb-4">Наши гиды</h1>
<p class="lead">Профессиональные и опытные гиды сделают ваше путешествие незабываемым</p>
</div>
</div>
<section class="hero-section compact bg-gradient-primary text-white py-5">
<div class="container text-center">
<h1 class="display-4 fw-bold mb-3">Наши профессиональные гиды</h1>
<p class="lead">Опытные гиды для незабываемых путешествий по Корее</p>
</div>
</section>
@@ -15,15 +11,15 @@
<div class="container">
<% if (guides.length === 0) { %>
<div class="text-center py-5">
<i class="fas fa-user-tie text-muted" style="font-size: 4rem;"></i>
<h3 class="mt-3 text-muted">Гиды не найдены</h3>
<i class="fas fa-user-tie fa-3x text-muted mb-3"></i>
<h3>Гиды не найдены</h3>
<p class="text-muted">В данный момент нет доступных гидов.</p>
</div>
<% } else { %>
<div class="row">
<% guides.forEach(guide => { %>
<% guides.forEach(function(guide) { %>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 shadow-sm">
<div class="card h-100 shadow-sm guide-card" data-guide-id="<%= guide.id %>" style="cursor: pointer; transition: transform 0.2s;">
<% if (guide.image_url && guide.image_url.trim()) { %>
<img src="<%= guide.image_url %>" class="card-img-top" alt="<%= guide.name %>" style="height: 250px; object-fit: cover;">
<% } else { %>
@@ -31,11 +27,39 @@
<% } %>
<div class="card-body d-flex flex-column">
<h5 class="card-title"><%= guide.name %></h5>
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0"><%= guide.name %></h5>
<div class="rating-display" data-target-type="guide" data-target-id="<%= guide.id %>">
<div class="d-flex align-items-center">
<span class="rating-percentage text-warning fw-bold">
<%
let percentage = parseFloat(guide.rating_percentage || 0);
%>
<%= Math.round(percentage) %>%
</span>
<small class="text-muted ms-1">
<%
let likes = parseInt(guide.likes || 0);
let dislikes = parseInt(guide.dislikes || 0);
let total = likes + dislikes;
%>
(<%= likes %>/<%= total %>)
</small>
</div>
<div class="rating-buttons mt-1">
<button class="btn btn-sm btn-outline-success like-btn" data-rating="1">
<i class="fas fa-thumbs-up"></i>
</button>
<button class="btn btn-sm btn-outline-danger dislike-btn" data-rating="-1">
<i class="fas fa-thumbs-down"></i>
</button>
</div>
</div>
</div>
<div class="mb-3">
<div class="mb-2">
<% if (guide.specialization === 'city') { %>
<span class="badge bg-info">
<span class="badge bg-primary">
<i class="fas fa-city me-1"></i>Городские туры
</span>
<% } else if (guide.specialization === 'mountain') { %>
@@ -43,14 +67,21 @@
<i class="fas fa-mountain me-1"></i>Горные походы
</span>
<% } else if (guide.specialization === 'fishing') { %>
<span class="badge bg-primary">
<i class="fas fa-fish me-1"></i>Рыбалка
<span class="badge bg-info">
<i class="fas fa-fish me-1"></i>Морская рыбалка
</span>
<% } %>
</div>
<div class="mb-2">
<small class="text-muted">
<i class="fas fa-briefcase me-1"></i>
Опыт: <%= guide.experience || 0 %> лет
</small>
</div>
<% if (guide.bio) { %>
<p class="card-text text-muted flex-grow-1"><%= truncateText(guide.bio, 120) %></p>
<p class="card-text text-muted flex-grow-1"><%= guide.bio.length > 100 ? guide.bio.substring(0, 100) + '...' : guide.bio %></p>
<% } %>
<div class="mt-auto">
@@ -63,29 +94,19 @@
</div>
<% } %>
<% if (guide.avg_rating) { %>
<div class="mb-2">
<%- generateStars(guide.avg_rating) %>
<small class="text-muted ms-2">(<%= parseFloat(guide.avg_rating).toFixed(1) %>)</small>
</div>
<% } %>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<small class="text-muted">
<i class="fas fa-calendar me-1"></i><%= guide.experience %> лет опыта
</small>
</div>
<div>
<small class="text-muted">
<i class="fas fa-route me-1"></i><%= guide.route_count || 0 %> туров
</small>
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small">
<i class="fas fa-calendar-check me-1"></i>
<%= guide.booking_count || 0 %> туров проведено
</div>
<% if (guide.hourly_rate) { %>
<div class="text-end">
<strong class="text-success">
₩<%= parseInt(guide.hourly_rate || 0).toLocaleString('ko-KR') %>/час
</strong>
</div>
<% } %>
</div>
<a href="/guides/<%= guide.id %>" class="btn btn-primary w-100">
<i class="fas fa-user me-1"></i>Подробнее
</a>
</div>
</div>
</div>
@@ -96,13 +117,85 @@
</div>
</section>
<!-- CTA Section -->
<section class="bg-light py-5">
<div class="container text-center">
<h2 class="mb-4">Нужен индивидуальный подход?</h2>
<p class="lead text-muted mb-4">Наши гиды готовы создать уникальный маршрут специально для вас!</p>
<a href="/contact" class="btn btn-primary btn-lg">
<i class="fas fa-envelope me-1"></i>Связаться с нами
</a>
</div>
</section>
<script>
// Guide card click handler and rating system
document.addEventListener('DOMContentLoaded', function() {
const guideCards = document.querySelectorAll('.guide-card');
guideCards.forEach(card => {
card.addEventListener('click', function(e) {
// Don't trigger card click if clicking on rating buttons
if (!e.target.closest('.rating-buttons')) {
const guideId = this.dataset.guideId;
// Redirect to guide details or show modal
window.location.href = `/guides/${guideId}`;
}
});
// Add hover effect
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-5px)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
});
});
// Rating system
document.addEventListener('click', function(e) {
if (e.target.closest('.like-btn') || e.target.closest('.dislike-btn')) {
e.preventDefault();
e.stopPropagation();
const button = e.target.closest('.like-btn') || e.target.closest('.dislike-btn');
const ratingDisplay = button.closest('.rating-display');
const targetType = ratingDisplay.dataset.targetType;
const targetId = ratingDisplay.dataset.targetId;
const rating = parseInt(button.dataset.rating);
// Send rating to server
fetch('/api/rating/' + targetType + '/' + targetId, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ rating: rating })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Update rating display
const percentageSpan = ratingDisplay.querySelector('.rating-percentage');
const countSpan = ratingDisplay.querySelector('small');
const percentage = data.percentage || 0;
const likes = data.likes || 0;
const total = data.total || 0;
percentageSpan.textContent = Math.round(percentage) + '%';
countSpan.textContent = `(${likes}/${total})`;
// Update button states
ratingDisplay.querySelectorAll('.rating-buttons button').forEach(btn => {
btn.classList.remove('btn-success', 'btn-danger');
btn.classList.add(btn.classList.contains('like-btn') ? 'btn-outline-success' : 'btn-outline-danger');
});
// Highlight selected button
if (rating === 1) {
button.classList.remove('btn-outline-success');
button.classList.add('btn-success');
} else if (rating === -1) {
button.classList.remove('btn-outline-danger');
button.classList.add('btn-danger');
}
}
})
.catch(error => {
console.error('Ошибка при отправке рейтинга:', error);
});
}
});
});
</script>

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white py-5">
<section class="hero-section compact bg-primary text-white py-5">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-4 text-center text-lg-start">

View File

@@ -3,7 +3,7 @@
<div class="hero-background"></div>
<div class="hero-overlay"></div>
<div class="container position-relative">
<div class="row align-items-center min-vh-100 py-5">
<div class="row align-items-center py-5" style="min-height: 70vh;">
<div class="col-lg-6" data-aos="fade-right">
<h1 class="hero-title display-3 fw-bold text-white mb-4">
Откройте красоту
@@ -175,7 +175,7 @@
<i class="fas fa-route display-4"></i>
</div>
<h3 class="stat-number fw-bold display-5 mb-2">50+</h3>
<p class="stat-label fs-5">Unique Tours</p>
<p class="stat-label fs-5">Уникальных туров</p>
</div>
</div>
<div class="col-lg-3 col-md-6" data-aos="fade-up" data-aos-delay="100">
@@ -193,7 +193,7 @@
<i class="fas fa-users display-4"></i>
</div>
<h3 class="stat-number fw-bold display-5 mb-2">1000+</h3>
<p class="stat-label fs-5">Happy Travelers</p>
<p class="stat-label fs-5">Счастливых путешественников</p>
</div>
</div>
<div class="col-lg-3 col-md-6" data-aos="fade-up" data-aos-delay="300">
@@ -202,7 +202,7 @@
<i class="fas fa-star display-4"></i>
</div>
<h3 class="stat-number fw-bold display-5 mb-2">4.9</h3>
<p class="stat-label fs-5">Average Rating</p>
<p class="stat-label fs-5">Средний рейтинг</p>
</div>
</div>
</div>
@@ -214,7 +214,7 @@
<div class="container">
<div class="text-center mb-5" data-aos="fade-up">
<h2 class="section-title display-5 fw-bold mb-3">Полезная информация о путешествиях</h2>
<p class="section-subtitle fs-5 text-muted">Tips, guides, and stories from Korea</p>
<p class="section-subtitle fs-5 text-muted">Советы, гиды и истории из Кореи</p>
</div>
<div class="row g-4">
@@ -249,8 +249,8 @@
<% }); %>
<% } else { %>
<div class="col-12 text-center py-5">
<p class="text-muted fs-5">No articles available at the moment.</p>
<a href="/articles" class="btn btn-primary">Browse All Articles</a>
<p class="text-muted fs-5">Пока нет доступных статей.</p>
<a href="/articles" class="btn btn-primary">Смотреть все статьи</a>
</div>
<% } %>
</div>
@@ -270,7 +270,7 @@
<div class="col-lg-8" data-aos="fade-right">
<h2 class="display-5 fw-bold mb-3">Готовы к вашему корейскому приключению?</h2>
<p class="fs-5 text-white-50 mb-0">
Join thousands of travelers who have discovered the magic of Korea with our expert guides.
Присоединяйтесь к тысячам путешественников, которые открыли для себя магию Кореи с нашими опытными гидами.
</p>
</div>
<div class="col-lg-4 text-lg-end" data-aos="fade-left">

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white">
<section class="hero-section compact bg-primary text-white">
<div class="container py-5">
<div class="row align-items-center">
<div class="col-lg-8">

View File

@@ -1,5 +1,5 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white text-center py-5">
<section class="hero-section compact bg-primary text-white text-center py-5">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-8 mx-auto">