✨ Компактные hero секции и улучшенная инициализация БД
🎨 UI улучшения: - Уменьшена высота синих панелей с 100vh до 70vh на главной - Добавлен класс .compact (25vh) для всех остальных страниц - Улучшена адаптивность для мобильных устройств - Обновлены все шаблоны с hero секциями 🚀 Инфраструктура: - Автоматическая инициализация базы данных при деплое - Улучшены мокапные данные (больше отзывов, бронирований, сообщений) - Добавлены настройки сайта в базу данных - Создан скрипт автоматического деплоя deploy.sh 📦 Система сборки: - Обновлен .gitignore с полным покрытием файлов - Добавлена папка для загрузок с .gitkeep - Улучшен README с инструкциями по запуску - ES модули для инициализации базы данных 🐛 Исправления: - Совместимость с ES модулями в Node.js - Правильная обработка ошибок инициализации БД - Корректные SQL запросы для PostgreSQL
This commit is contained in:
22
.gitignore
vendored
22
.gitignore
vendored
@@ -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
|
||||
32
README.md
32
README.md
@@ -7,6 +7,38 @@
|
||||

|
||||

|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Автоматический деплой
|
||||
```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
77
database/init-database.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
73
database/rating-system-migration.sql
Normal file
73
database/rating-system-migration.sql
Normal 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
106
database/run-migration.js
Normal 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
148
database/seed-guide-data.js
Normal 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
70
deploy.sh
Executable 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 ""
|
||||
@@ -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
2
public/uploads/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Этот файл обеспечивает создание папки uploads в репозитории
|
||||
# Папка нужна для загрузки изображений пользователей
|
||||
13
src/app.js
13
src/app.js
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
178
src/routes/ratings.js
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
316
views/guides/index-backup.ejs
Normal file
316
views/guides/index-backup.ejs
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user