diff --git a/.gitignore b/.gitignore index e2a45ad..a1fcdf3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index 0ddc90a..d4c6d17 100644 --- a/README.md +++ b/README.md @@ -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 +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 + ## 🌟 Особенности ### 🎯 Основные функции diff --git a/database/init-database.js b/database/init-database.js new file mode 100644 index 0000000..f247642 --- /dev/null +++ b/database/init-database.js @@ -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); + }); +} \ No newline at end of file diff --git a/database/mock-data.sql b/database/mock-data.sql index a78db23..c9c355f 100644 --- a/database/mock-data.sql +++ b/database/mock-data.sql @@ -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'); diff --git a/database/rating-system-migration.sql b/database/rating-system-migration.sql new file mode 100644 index 0000000..3ca3a26 --- /dev/null +++ b/database/rating-system-migration.sql @@ -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; \ No newline at end of file diff --git a/database/run-migration.js b/database/run-migration.js new file mode 100644 index 0000000..ae7cb40 --- /dev/null +++ b/database/run-migration.js @@ -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(); \ No newline at end of file diff --git a/database/seed-guide-data.js b/database/seed-guide-data.js new file mode 100644 index 0000000..97ccae8 --- /dev/null +++ b/database/seed-guide-data.js @@ -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(); \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..c713ba8 --- /dev/null +++ b/deploy.sh @@ -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 "" \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css index 366db19..28276fa 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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; } } diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..267131d --- /dev/null +++ b/public/uploads/.gitkeep @@ -0,0 +1,2 @@ +# Этот файл обеспечивает создание папки uploads в репозитории +# Папка нужна для загрузки изображений пользователей \ No newline at end of file diff --git a/src/app.js b/src/app.js index 769b9d2..8cdac38 100644 --- a/src/app.js +++ b/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) => { diff --git a/src/config/adminjs-simple.js b/src/config/adminjs-simple.js index a828474..ee7f164 100644 --- a/src/config/adminjs-simple.js +++ b/src/config/adminjs-simple.js @@ -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', diff --git a/src/routes/guides.js b/src/routes/guides.js index 838bfda..ca5d6c5 100644 --- a/src/routes/guides.js +++ b/src/routes/guides.js @@ -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 = []; diff --git a/src/routes/ratings.js b/src/routes/ratings.js new file mode 100644 index 0000000..a78c3d9 --- /dev/null +++ b/src/routes/ratings.js @@ -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; \ No newline at end of file diff --git a/views/about.ejs b/views/about.ejs index 76792f1..88eb800 100644 --- a/views/about.ejs +++ b/views/about.ejs @@ -1,5 +1,5 @@ -
+
diff --git a/views/articles/detail.ejs b/views/articles/detail.ejs index 14e3b9a..08c07db 100644 --- a/views/articles/detail.ejs +++ b/views/articles/detail.ejs @@ -1,5 +1,5 @@ -
+
diff --git a/views/articles/index.ejs b/views/articles/index.ejs index ac0b4ee..16e0458 100644 --- a/views/articles/index.ejs +++ b/views/articles/index.ejs @@ -1,5 +1,5 @@ -
+
diff --git a/views/contact.ejs b/views/contact.ejs index 8423ea8..afd4204 100644 --- a/views/contact.ejs +++ b/views/contact.ejs @@ -1,5 +1,5 @@ -
+
diff --git a/views/guides/index-backup.ejs b/views/guides/index-backup.ejs new file mode 100644 index 0000000..f45a7a7 --- /dev/null +++ b/views/guides/index-backup.ejs @@ -0,0 +1,316 @@ + +
+
+
+
+

Наши гиды

+

Профессиональные и опытные гиды сделают ваше путешествие незабываемым

+
+
+
+
+ + +
+
+ <% if (guides.length === 0) { %> +
+ +

Гиды не найдены

+

В данный момент нет доступных гидов.

+
+ <% } else { %> +
+ <% guides.forEach(guide => { %> +
+
+ <% if (guide.image_url && guide.image_url.trim()) { %> + <%= guide.name %> + <% } else { %> + <%= guide.name %> + <% } %> + +
+
+
<%= guide.name %>
+
+
+ + <% if (guide.rating_percentage > 0) { %> + <%= parseFloat(guide.rating_percentage).toFixed(0) %>% + <% } else { %> + 0% + <% } %> + + + (<%= guide.likes || 0 %>/<%= (parseInt(guide.likes || 0) + parseInt(guide.dislikes || 0)) %>) + +
+
+ + +
+
+
+ +
+ <% if (guide.specialization === 'city') { %> + + Городские туры + + <% } else if (guide.specialization === 'mountain') { %> + + Горные походы + + <% } else if (guide.specialization === 'fishing') { %> + + Рыбалка + + <% } else { %> + + Универсальный + + <% } %> + + <% if (guide.experience > 0) { %> + + <%= guide.experience %> лет опыта + + <% } %> +
+ + <% if (guide.bio) { %> +

<%= guide.bio.length > 100 ? guide.bio.substring(0, 100) + '...' : guide.bio %>

+ <% } %> + +
+ <% if (guide.languages) { %> +
+ + + <%= guide.languages %> + +
+ <% } %> + + <% if (guide.avg_rating) { %> +
+ + + + + + (<%= parseFloat(guide.avg_rating).toFixed(1) %>) +
+ <% } %> + +
+
+ + <%= guide.booking_count || 0 %> туров проведено +
+ <% if (guide.hourly_rate) { %> +
+ ₩<%= parseInt(guide.hourly_rate || 0).toLocaleString('ko-KR') %>/час +
+ <% } %> +
+
+
+
+
+ <% }); %> +
+ <% } %> +
+
+ + + + + + + + + <%= guide.experience %> лет опыта + +
+
+ + <%= guide.route_count || 0 %> туров + +
+
+ + + Подробнее + +
+
+
+
+ <% }); %> +
+ <% } %> +
+
+ + +
+
+

Нужен индивидуальный подход?

+

Наши гиды готовы создать уникальный маршрут специально для вас!

+ + Связаться с нами + +
+
\ No newline at end of file diff --git a/views/guides/index.ejs b/views/guides/index.ejs index 493a8d0..4e104d4 100644 --- a/views/guides/index.ejs +++ b/views/guides/index.ejs @@ -1,12 +1,8 @@ -
-
-
-
-

Наши гиды

-

Профессиональные и опытные гиды сделают ваше путешествие незабываемым

-
-
+
+
+

Наши профессиональные гиды

+

Опытные гиды для незабываемых путешествий по Корее

@@ -15,15 +11,15 @@
<% if (guides.length === 0) { %>
- -

Гиды не найдены

+ +

Гиды не найдены

В данный момент нет доступных гидов.

<% } else { %>
- <% guides.forEach(guide => { %> + <% guides.forEach(function(guide) { %>
-
+
<% if (guide.image_url && guide.image_url.trim()) { %> <%= guide.name %> <% } else { %> @@ -31,11 +27,39 @@ <% } %>
-
<%= guide.name %>
+
+
<%= guide.name %>
+
+
+ + <% + let percentage = parseFloat(guide.rating_percentage || 0); + %> + <%= Math.round(percentage) %>% + + + <% + let likes = parseInt(guide.likes || 0); + let dislikes = parseInt(guide.dislikes || 0); + let total = likes + dislikes; + %> + (<%= likes %>/<%= total %>) + +
+
+ + +
+
+
-
+
<% if (guide.specialization === 'city') { %> - + Городские туры <% } else if (guide.specialization === 'mountain') { %> @@ -43,14 +67,21 @@ Горные походы <% } else if (guide.specialization === 'fishing') { %> - - Рыбалка + + Морская рыбалка <% } %>
+
+ + + Опыт: <%= guide.experience || 0 %> лет + +
+ <% if (guide.bio) { %> -

<%= truncateText(guide.bio, 120) %>

+

<%= guide.bio.length > 100 ? guide.bio.substring(0, 100) + '...' : guide.bio %>

<% } %>
@@ -63,29 +94,19 @@
<% } %> - <% if (guide.avg_rating) { %> -
- <%- generateStars(guide.avg_rating) %> - (<%= parseFloat(guide.avg_rating).toFixed(1) %>) -
- <% } %> - -
-
- - <%= guide.experience %> лет опыта - -
-
- - <%= guide.route_count || 0 %> туров - +
+
+ + <%= guide.booking_count || 0 %> туров проведено
+ <% if (guide.hourly_rate) { %> +
+ + ₩<%= parseInt(guide.hourly_rate || 0).toLocaleString('ko-KR') %>/час + +
+ <% } %>
- - - Подробнее -
@@ -96,13 +117,85 @@
- -
-
-

Нужен индивидуальный подход?

-

Наши гиды готовы создать уникальный маршрут специально для вас!

- - Связаться с нами - -
-
\ No newline at end of file + \ No newline at end of file diff --git a/views/guides/profile.ejs b/views/guides/profile.ejs index a555187..fce5820 100644 --- a/views/guides/profile.ejs +++ b/views/guides/profile.ejs @@ -1,5 +1,5 @@ -
+
diff --git a/views/index.ejs b/views/index.ejs index 7a66039..df70552 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -3,7 +3,7 @@
-
+

Откройте красоту @@ -175,7 +175,7 @@

50+

-

Unique Tours

+

Уникальных туров

@@ -193,7 +193,7 @@

1000+

-

Happy Travelers

+

Счастливых путешественников

@@ -202,7 +202,7 @@

4.9

-

Average Rating

+

Средний рейтинг

@@ -214,7 +214,7 @@

Полезная информация о путешествиях

-

Tips, guides, and stories from Korea

+

Советы, гиды и истории из Кореи

@@ -249,8 +249,8 @@ <% }); %> <% } else { %>
-

No articles available at the moment.

- Browse All Articles +

Пока нет доступных статей.

+ Смотреть все статьи
<% } %>
@@ -270,7 +270,7 @@

Готовы к вашему корейскому приключению?

- Join thousands of travelers who have discovered the magic of Korea with our expert guides. + Присоединяйтесь к тысячам путешественников, которые открыли для себя магию Кореи с нашими опытными гидами.

diff --git a/views/routes/detail.ejs b/views/routes/detail.ejs index 8b68c45..d3512e8 100644 --- a/views/routes/detail.ejs +++ b/views/routes/detail.ejs @@ -1,5 +1,5 @@ -
+
diff --git a/views/routes/index.ejs b/views/routes/index.ejs index 1232356..158541c 100644 --- a/views/routes/index.ejs +++ b/views/routes/index.ejs @@ -1,5 +1,5 @@ -
+