Initial commit: Korea Tourism Agency website with AdminJS

- Full-stack Node.js/Express application with PostgreSQL
- Modern ES modules architecture
- AdminJS admin panel with Sequelize ORM
- Tourism routes, guides, articles, bookings management
- Responsive Bootstrap 5 frontend
- Docker containerization with docker-compose
- Complete database schema with migrations
- Authentication system for admin panel
- Dynamic placeholder images for tour categories
This commit is contained in:
2025-11-29 18:13:17 +09:00
commit 409e6c146b
53 changed files with 16195 additions and 0 deletions

7
.adminjs/bundle.js Normal file
View File

@@ -0,0 +1,7 @@
(function () {
'use strict';
AdminJS.UserComponents = {};
})();
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlcyI6WyJlbnRyeS5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJBZG1pbkpTLlVzZXJDb21wb25lbnRzID0ge31cbiJdLCJuYW1lcyI6WyJBZG1pbkpTIiwiVXNlckNvbXBvbmVudHMiXSwibWFwcGluZ3MiOiI7OztDQUFBQSxPQUFPLENBQUNDLGNBQWMsR0FBRyxFQUFFOzs7Ozs7In0=

1
.adminjs/entry.js Normal file
View File

@@ -0,0 +1 @@
AdminJS.UserComponents = {}

72
.env.example Normal file
View File

@@ -0,0 +1,72 @@
# Korea Tourism Agency - Environment Variables Example
# Копируйте этот файл в .env и настройте под ваши параметры
# ==============================================
# Database Configuration
# ==============================================
DB_HOST=postgres
DB_PORT=5432
DB_NAME=korea_tourism
DB_USER=tourism_user
DB_PASSWORD=tourism_password
# ==============================================
# Application Configuration
# ==============================================
PORT=3000
NODE_ENV=development
SESSION_SECRET=korea-tourism-secret-key-2024-change-in-production
# ==============================================
# File Upload Configuration
# ==============================================
UPLOAD_PATH=/app/public/uploads
MAX_FILE_SIZE=5242880
# ==============================================
# Site Information
# ==============================================
SITE_NAME=Korea Tourism Agency
SITE_DESCRIPTION=Discover Korea's hidden gems with our guided tours
CONTACT_EMAIL=info@koreatourism.com
CONTACT_PHONE=+82-2-1234-5678
COMPANY_ADDRESS=서울특별시 중구 세종대로 110
ADMIN_EMAIL=admin@koreatourism.com
# ==============================================
# Admin Configuration
# ==============================================
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# ==============================================
# Email Configuration (Optional)
# ==============================================
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=your-email@gmail.com
# SMTP_PASSWORD=your-app-password
# SMTP_FROM=Korea Tourism <noreply@koreatourism.com>
# ==============================================
# External API Keys (Optional)
# ==============================================
# GOOGLE_MAPS_API_KEY=your-google-maps-api-key
# WEATHER_API_KEY=your-weather-api-key
# KAKAO_MAP_API_KEY=your-kakao-map-api-key
# ==============================================
# Security Configuration
# ==============================================
# CORS_ORIGIN=http://localhost:3000
# RATE_LIMIT_WINDOW=900000
# RATE_LIMIT_MAX=100
# ==============================================
# Production Settings
# ==============================================
# В production обязательно изменить:
# NODE_ENV=production
# SESSION_SECRET=генерировать-новый-безопасный-ключ
# ADMIN_PASSWORD=создать-безопасный-пароль
# Настроить SSL и реальные домены

36
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,36 @@
# Korea Tourism Agency Website
Туристическое агентство для внутренних поездок по Корее.
## Технический стек
- Backend: Node.js + Express.js
- Database: PostgreSQL
- Frontend: HTML/CSS/JavaScript с адаптивным дизайном
- Deployment: Docker + Docker Compose
- Environment: Переменные окружения через .env
## Функциональность
- Каталог туристических маршрутов (города, горы, морские рыбалки)
- Управление гидами
- Система статей и блога
- Административная панель
- Адаптивный и стильный дизайн
## Структура проекта
```
/
├── src/ # Исходный код приложения
├── public/ # Статические файлы (CSS, JS, images)
├── views/ # EJS шаблоны
├── database/ # Миграции и схемы БД
├── docker/ # Docker конфигурации
└── docs/ # Документация
```
## Основные сущности
- Routes (маршруты): city tours, mountain trips, fishing tours
- Guides (гиды): профили, специализации, языки
- Articles (статьи): блог, полезная информация
- Users (пользователи): администраторы, клиенты
Все данные конфигурации вынесены в .env файл для безопасности.

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Environment variables
.env
.env.local
.env.production
# Logs
*.debug
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Database files
*.sqlite
*.sqlite3
*.db
# Node modules
node_modules/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Build output
dist/
build/
# History
.history/
# Docker
.dockerignore
# Temporary files
tmp/
temp/

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Create uploads directory
RUN mkdir -p public/uploads
# Copy application files
COPY . .
# Expose port
EXPOSE 3000
# Command to run the application
CMD ["npm", "run", "dev"]

44
Dockerfile.prod Normal file
View File

@@ -0,0 +1,44 @@
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Remove development files
RUN rm -rf .git .gitignore docker-compose.yml Dockerfile README.md
# Final stage
FROM node:18-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S tourism -u 1001
# Copy from builder
COPY --from=builder --chown=tourism:nodejs /app .
# Create uploads directory
RUN mkdir -p public/uploads && chown tourism:nodejs public/uploads
# Switch to non-root user
USER tourism
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
# Start application
CMD ["npm", "start"]

43
README.md Normal file

File diff suppressed because one or more lines are too long

75
database/migrate.js Normal file
View File

@@ -0,0 +1,75 @@
const fs = require('fs');
const path = require('path');
const db = require('../src/config/database');
async function runMigrations() {
try {
console.log('💾 Starting database migration...');
// Check if database is connected
await db.query('SELECT 1');
console.log('✅ Database connection successful');
// Read and execute schema
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
await db.query(schema);
console.log('✅ Database schema created successfully');
// Insert default admin user
const bcrypt = require('bcryptjs');
const hashedPassword = await bcrypt.hash('admin123', 10);
try {
await db.query(`
INSERT INTO admins (username, password, name, email, role)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (username) DO NOTHING
`, ['admin', hashedPassword, 'Administrator', 'admin@example.com', 'admin']);
console.log('✅ Default admin user created');
} catch (error) {
console.log(' Admin user already exists');
}
// Insert default site settings
const defaultSettings = [
['site_name', 'Korea Tourism Agency', 'text', 'Website name'],
['site_description', 'Discover Korea\'s hidden gems with our guided tours', 'text', 'Site description'],
['contact_email', 'info@koreatourism.com', 'text', 'Contact email'],
['contact_phone', '+82-2-1234-5678', 'text', 'Contact phone'],
['contact_address', 'Seoul, South Korea', 'text', 'Contact address'],
['facebook_url', '', 'text', 'Facebook page URL'],
['instagram_url', '', 'text', 'Instagram profile URL'],
['twitter_url', '', 'text', 'Twitter profile URL'],
['booking_enabled', 'true', 'boolean', 'Enable online booking'],
['maintenance_mode', 'false', 'boolean', 'Maintenance mode']
];
for (const [key, value, type, description] of defaultSettings) {
try {
await db.query(`
INSERT INTO site_settings (setting_key, setting_value, setting_type, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (setting_key) DO NOTHING
`, [key, value, type, description]);
} catch (error) {
console.log(` Setting ${key} already exists`);
}
}
console.log('✅ Default site settings inserted');
console.log('✨ Migration completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Migration failed:', error);
process.exit(1);
}
}
if (require.main === module) {
runMigrations();
}
module.exports = { runMigrations };

109
database/mock-data.sql Normal file
View File

@@ -0,0 +1,109 @@
-- Мокапные данные для туристического сайта
-- Удаляем старые данные и заполняем новыми
-- Очистка таблиц
TRUNCATE TABLE bookings, reviews, routes, guides, articles, admins, contact_messages, site_settings RESTART IDENTITY CASCADE;
-- Админы
INSERT INTO admins (username, password, name, email, role, created_at) VALUES
('admin', '$2b$12$LQv3c1yqBwEHbLn5F2x/3OlzqXrJQ9vSf9Gm7ZwTsYcAb3DeF4gHi', 'Администратор', 'admin@koreatour.com', 'super_admin', NOW()),
('manager', '$2b$12$LQv3c1yqBwEHbLn5F2x/3OlzqXrJQ9vSf9Gm7ZwTsYcAb3DeF4gHi', 'Менеджер', 'manager@koreatour.com', 'admin', NOW());
-- Гиды
INSERT INTO guides (name, email, phone, languages, specialization, bio, image_url, hourly_rate, is_active, created_at) VALUES
('Ким Мин Джун', 'kim.minjun@guide.com', '+82-10-1234-5678', ARRAY['русский', 'корейский', 'английский'], 'city', 'Опытный гид со знанием истории и культуры Кореи. Специализируется на дворцах Сеула и традиционных деревнях.', '/images/guides/kim-minjun.jpg', 25000, true, NOW()),
('Пак Со Ён', 'park.soyeon@guide.com', '+82-10-2345-6789', ARRAY['русский', 'корейский', 'китайский'], 'mountain', 'Инструктор по горному туризму. Знает все тропы национальных парков и безопасные маршруты.', '/images/guides/park-soyeon.jpg', 20000, true, NOW()),
('Ли Дон Хёк', 'lee.donhyuk@guide.com', '+82-10-3456-7890', ARRAY['корейский', 'английский', 'японский'], 'fishing', 'Капитан с большим опытом морской рыбалки. Знает лучшие места для ловли у берегов Пусана.', '/images/guides/lee-donhyuk.jpg', 30000, true, NOW()),
('Чой Ю На', 'choi.yuna@guide.com', '+82-10-4567-8901', ARRAY['русский', 'корейский'], 'city', 'Эксперт корейской кухни. Проводит кулинарные мастер-классы и дегустационные туры по Сеулу.', '/images/guides/choi-yuna.jpg', 22000, true, NOW()),
('Юн Тэ Гу', 'yun.taegu@guide.com', '+82-10-5678-9012', ARRAY['корейский', 'английский'], 'city', 'Историк и археолог. Специализируется на древних памятниках и буддийских храмах Кореи.', '/images/guides/yun-taegu.jpg', 24000, true, NOW());
-- Маршруты
INSERT INTO routes (title, description, type, duration, price, difficulty_level, max_group_size, guide_id, image_url, content, included_services, is_active, is_featured, created_at) VALUES
('Сеул: Дворцы и традиции', 'Познакомьтесь с королевскими дворцами Сеула, традиционными районами и современной культурой K-pop.', 'city', 3, 450000, 'easy', 12, 1, '/images/tours/seoul-palaces-1.jpg',
'День 1: Дворец Кёнбоккун, смена караула, район Букчон Ханок. День 2: Дворец Чхандоккун, Тайный сад, рынок Инсадон. День 3: Намдэмун, Мёндон, башня Намсан, шоу K-pop.',
ARRAY['Входные билеты в дворцы', 'Трансфер', 'Гид', 'Обеды'], true, true, NOW()),
('Пусан: Морской город', 'Откройте для себя главный порт Кореи, его пляжи, рыбные рынки и современную архитектуру.', 'city', 2, 320000, 'easy', 10, 4, '/images/tours/busan-1.jpg',
'День 1: Пляж Хэундэ, храм Хэдон Ёнгунса, рыбный рынок Чагальчи. День 2: Остров Орюкдо, деревня Камчон, башня Пусан.',
ARRAY['Трансфер', 'Гид', 'Входные билеты', 'Обеды'], true, true, NOW()),
('Кёнджу: Древняя столица', 'Посетите древнюю столицу династии Силла с её храмами, гробницами и археологическими памятниками.', 'city', 2, 380000, 'easy', 8, 5, '/images/tours/gyeongju-1.jpg',
'День 1: Грот Соккурам, храм Пульгукса, музей Кёнджу. День 2: Парк Тумули, пруд Анапчи, обсерватория Чхомсондэ.',
ARRAY['Трансфер', 'Гид-историк', 'Входные билеты', 'Обеды'], true, false, NOW()),
('Сораксан: Горные вершины', 'Треккинг в одном из красивейших национальных парков Кореи с водопадами и горными храмами.', 'mountain', 4, 680000, 'moderate', 8, 2, '/images/tours/seoraksan-1.jpg',
'День 1: Прибытие, водопад Юктам, храм Синхынса. День 2: Подъём на Ульсанбави, канатная дорога. День 3: Треккинг к пику Тэчхонбон. День 4: Долина Пэктам, возвращение.',
ARRAY['Проживание в горной хижине', 'Все питание', 'Гид-инструктор', 'Снаряжение'], true, true, NOW()),
('Чирисан: Духовный путь', 'Поход по священным тропам самого высокого материкового пика Кореи с посещением древних храмов.', 'mountain', 5, 750000, 'moderate', 6, 2, '/images/tours/jirisan-1.jpg',
'День 1: Храм Хваомса, начало восхождения. День 2: Восхождение на пик Чонванбон. День 3: Переход через хребет. День 4: Храм Ссанггеса, медитация. День 5: Спуск, завершение маршрута.',
ARRAY['Проживание в храмах и хижинах', 'Вегетарианское питание', 'Гид', 'Церемонии'], true, false, NOW()),
('Халласан (остров Чеджу)', 'Восхождение на высочайшую вершину Кореи на вулканическом острове Чеджу.', 'mountain', 3, 580000, 'moderate', 10, 2, '/images/tours/hallasan-1.jpg',
'День 1: Прибытие на Чеджу, тропа Сонпанак. День 2: Восхождение на Халласан, кратерное озеро. День 3: Водопад Чонбан, базальтовые колонны.',
ARRAY['Авиаперелёт', 'Проживание', 'Гид', 'Трансфер'], true, true, NOW()),
('Рыбалка у берегов Пусана', 'Морская рыбалка с опытным капитаном в богатых рыбой водах Корейского пролива.', 'fishing', 2, 420000, 'easy', 6, 3, '/images/tours/fishing-busan-1.jpg',
'День 1: Инструктаж, выход в море, рыбалка на морского леща. День 2: Глубоководная рыбалка, приготовление улова.',
ARRAY['Катер', 'Снасти', 'Инструктор', 'Приготовление рыбы'], true, true, NOW()),
('Рыбалка на Восточном море', 'Рыбалка в водах Восточного моря с ночёвкой на рыбацком судне и изучением традиционных методов ловли.', 'fishing', 3, 650000, 'moderate', 4, 3, '/images/tours/east-sea-fishing-1.jpg',
'День 1: Выход из порта Сокчо, дневная рыбалка. День 2: Ночная рыбалка на кальмаров, ночёвка на судне. День 3: Утренняя рыбалка, возвращение в порт.',
ARRAY['Судно', 'Все снасти', 'Питание на борту', 'Опытная команда'], true, false, NOW());
-- Статьи
INSERT INTO articles (title, content, excerpt, category, image_url, author_id, views, is_published, created_at, updated_at) VALUES
('Лучшее время для посещения Кореи',
'Корея прекрасна в любое время года, но каждый сезон имеет свои особенности. Весна (март-май) - один из лучших периодов для посещения Кореи. Температура комфортная (15-20°C), цветёт сакура, особенно красиво в парках Сеула и Пусана. Лето жаркое и влажное с частыми дождями. Осень - самый популярный сезон среди туристов с потрясающими красками в горах. Зима холодная, но отличное время для горнолыжного спорта.',
'Узнайте, когда лучше всего посещать Корею в зависимости от ваших предпочтений и планируемых активностей.',
'travel-tips', '/images/articles/korea-seasons.jpg', 1, 1247, true, NOW(), NOW()),
('Корейская кухня для туристов',
'Корейская кухня - это больше чем просто кимчи. Кимчи - ферментированные овощи, главное блюдо корейской кухни. Пибимпап - рис с овощами, мясом и яйцом. Пульгоги - маринованная говядина-гриль. Самгёпсаль - свиная грудинка на гриле. Соджу - корейская водка, самый популярный алкогольный напиток. Лучшие рестораны находятся в районах Мёндон и Хонгдэ в Сеуле.',
'Полный гид по корейской кухне: что попробовать, где поесть и как заказывать.',
'food', '/images/articles/korean-food.jpg', 1, 892, true, NOW(), NOW()),
('Топ-10 храмов Кореи',
'Буддийские храмы Кореи - архитектурные шедевры. Пульгукса (Кёнджу) - объект ЮНЕСКО. Хэинса - хранилище Трипитаки Кореаны. Чогеса - главный храм в Сеуле. Синхынса - храм в Сораксане с гигантской статуей Будды. При посещении снимайте обувь, говорите тихо, фотографируйте без вспышки.',
'Откройте для себя духовное наследие Кореи через посещение её самых значимых буддийских храмов.',
'culture', '/images/articles/korean-temples.jpg', 2, 634, true, NOW(), NOW()),
('Что взять с собой в поход по корейским горам',
'Для походов в корейские горы нужно: треккинговые ботинки с хорошим протектором, дождевик (погода меняется быстро), слои одежды, головной убор, солнцезащитный крем. Тропы хорошо размечены, есть места отдыха каждые 1-2 км, питьевая вода в хижинах. Обязательно регистрируйтесь на входе в парк и не сходите с троп.',
'Полный список снаряжения и советы по безопасности для походов в корейские горы.',
'nature', '/images/articles/hiking-gear.jpg', 2, 423, true, NOW(), NOW());
-- Отзывы
INSERT INTO reviews (route_id, customer_name, customer_email, rating, comment, is_approved, created_at) VALUES
(1, 'Анна Петрова', 'anna.petrova@email.com', 5, 'Три дня в Сеуле пролетели как один день. Гид Ким Мин Джун просто потрясающий - знает историю каждого камня во дворцах. Особенно понравился Тайный сад и шоу K-pop. Обязательно вернёмся!', true, NOW() - INTERVAL '5 days'),
(4, 'Дмитрий Соколов', 'dmitry.sokolov@email.com', 5, 'Сораксан превзошёл все ожидания! Пак Со Ён - профессиональный гид, который заботится о безопасности группы. Виды с Ульсанбави просто космические. Рекомендую всем любителям гор!', true, NOW() - INTERVAL '12 days'),
(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');
-- Настройки сайта
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 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'),
(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');
-- Сообщения от посетителей
INSERT INTO contact_messages (name, email, phone, subject, message, status, created_at) VALUES
('Анна Смирнова', 'anna.smirnova@email.com', '+7-912-567-8901', 'Вопрос о групповой скидке', 'Здравствуйте! Планируем поездку группой из 15 человек. Есть ли групповые скидки на туры по Сеулу?', 'unread', NOW() - INTERVAL '2 hours'),
('Петр Козлов', 'petr.kozlov@email.com', '', 'Безопасность горных походов', 'Интересует безопасность походов в Сораксан. Какое снаряжение предоставляете? Есть ли медицинская поддержка?', 'read', NOW() - INTERVAL '1 day'),
('Ольга Волкова', 'olga.volkova@email.com', '+7-967-678-9012', 'Индивидуальный тур', 'Можете ли организовать индивидуальный тур по Кёнджу на 5 дней с посещением всех исторических мест?', 'unread', NOW() - INTERVAL '6 hours');

155
database/schema.sql Normal file
View File

@@ -0,0 +1,155 @@
-- Korea Tourism Agency Database Schema
-- Создание основных таблиц для туристического агентства
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Таблица администраторов
CREATE TABLE IF NOT EXISTS admins (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
role VARCHAR(20) DEFAULT 'admin',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица гидов
CREATE TABLE IF NOT EXISTS guides (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE,
phone VARCHAR(20),
bio TEXT,
specialization VARCHAR(20) NOT NULL CHECK (specialization IN ('city', 'mountain', 'fishing')),
languages TEXT[], -- Массив языков
experience INTEGER DEFAULT 0, -- Опыт в годах
image_url VARCHAR(255),
hourly_rate DECIMAL(8,2),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица маршрутов/туров
CREATE TABLE IF NOT EXISTS routes (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT NOT NULL,
content TEXT, -- Полное описание
type VARCHAR(20) NOT NULL CHECK (type IN ('city', 'mountain', 'fishing')),
price DECIMAL(10,2) NOT NULL,
duration INTEGER NOT NULL, -- Длительность в часах
difficulty_level VARCHAR(10) DEFAULT 'easy' CHECK (difficulty_level IN ('easy', 'moderate', 'hard')),
max_group_size INTEGER DEFAULT 10,
included_services TEXT[],
meeting_point TEXT,
image_url VARCHAR(255),
guide_id INTEGER REFERENCES guides(id),
is_featured BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица статей
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
excerpt TEXT,
content TEXT NOT NULL,
category VARCHAR(50) NOT NULL CHECK (category IN ('travel-tips', 'culture', 'food', 'nature', 'history')),
image_url VARCHAR(255),
author_id INTEGER REFERENCES admins(id),
views INTEGER DEFAULT 0,
is_published BOOLEAN DEFAULT false,
meta_description TEXT,
meta_keywords TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица бронирований
CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
route_id INTEGER REFERENCES routes(id) NOT NULL,
guide_id INTEGER REFERENCES guides(id),
customer_name VARCHAR(100) NOT NULL,
customer_email VARCHAR(100) NOT NULL,
customer_phone VARCHAR(20),
preferred_date DATE,
group_size INTEGER DEFAULT 1,
total_price DECIMAL(10,2),
special_requirements TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled', 'completed')),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица отзывов
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
route_id INTEGER REFERENCES routes(id),
guide_id INTEGER REFERENCES guides(id),
booking_id INTEGER REFERENCES bookings(id),
customer_name VARCHAR(100) NOT NULL,
customer_email VARCHAR(100),
rating INTEGER CHECK (rating >= 1 AND rating <= 5) NOT NULL,
comment TEXT,
is_approved BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица сообщений с формы контактов
CREATE TABLE IF NOT EXISTS contact_messages (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL,
phone VARCHAR(20),
subject VARCHAR(200),
message TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'unread' CHECK (status IN ('unread', 'read', 'replied')),
admin_notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица настроек сайта
CREATE TABLE IF NOT EXISTS site_settings (
id SERIAL PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT,
setting_type VARCHAR(20) DEFAULT 'text' CHECK (setting_type IN ('text', 'number', 'boolean', 'json')),
description TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Создание индексов для производительности
CREATE INDEX IF NOT EXISTS idx_routes_type ON routes(type);
CREATE INDEX IF NOT EXISTS idx_routes_active ON routes(is_active);
CREATE INDEX IF NOT EXISTS idx_routes_featured ON routes(is_featured);
CREATE INDEX IF NOT EXISTS idx_guides_specialization ON guides(specialization);
CREATE INDEX IF NOT EXISTS idx_guides_active ON guides(is_active);
CREATE INDEX IF NOT EXISTS idx_articles_category ON articles(category);
CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(is_published);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_bookings_date ON bookings(preferred_date);
CREATE INDEX IF NOT EXISTS idx_reviews_rating ON reviews(rating);
-- Создание триггеров для автоматического обновления updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_admins_updated_at BEFORE UPDATE ON admins FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_guides_updated_at BEFORE UPDATE ON guides FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_routes_updated_at BEFORE UPDATE ON routes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_articles_updated_at BEFORE UPDATE ON articles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_bookings_updated_at BEFORE UPDATE ON bookings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_site_settings_updated_at BEFORE UPDATE ON site_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

250
database/seed.js Normal file
View File

@@ -0,0 +1,250 @@
const db = require('../src/config/database');
async function seedDatabase() {
try {
console.log('🌱 Starting database seeding...');
// Seed guides
const guides = [
{
name: 'Kim Min-jun',
email: 'minjun@koreatourism.com',
phone: '+82-10-1234-5678',
bio: 'Experienced Seoul city guide with 8 years of expertise in Korean history and culture. Fluent in English, Japanese, and Chinese.',
specialization: 'city',
languages: ['Korean', 'English', 'Japanese', 'Chinese'],
experience: 8,
hourly_rate: 50000
},
{
name: 'Park So-young',
email: 'soyoung@koreatourism.com',
phone: '+82-10-2345-6789',
bio: 'Mountain hiking specialist with deep knowledge of Korean national parks. Safety-certified guide with wilderness first aid training.',
specialization: 'mountain',
languages: ['Korean', 'English'],
experience: 6,
hourly_rate: 45000
},
{
name: 'Lee Sung-ho',
email: 'sungho@koreatourism.com',
phone: '+82-10-3456-7890',
bio: 'Professional fishing guide specializing in coastal and river fishing. 10 years of experience with traditional Korean fishing techniques.',
specialization: 'fishing',
languages: ['Korean', 'English'],
experience: 10,
hourly_rate: 60000
},
{
name: 'Choi Yeon-seo',
email: 'yeonseo@koreatourism.com',
phone: '+82-10-4567-8901',
bio: 'Cultural heritage expert specializing in traditional Korean architecture and temples. PhD in Korean History.',
specialization: 'city',
languages: ['Korean', 'English', 'Mandarin'],
experience: 12,
hourly_rate: 55000
}
];
for (const guide of guides) {
await db.query(`
INSERT INTO guides (name, email, phone, bio, specialization, languages, experience, hourly_rate)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (email) DO NOTHING
`, [guide.name, guide.email, guide.phone, guide.bio, guide.specialization, guide.languages, guide.experience, guide.hourly_rate]);
}
console.log('✅ Guides seeded successfully');
// Get guide IDs for routes
const guideRows = await db.query('SELECT id, name, specialization FROM guides ORDER BY id');
const guideMap = {};
guideRows.rows.forEach(guide => {
if (!guideMap[guide.specialization]) guideMap[guide.specialization] = [];
guideMap[guide.specialization].push(guide.id);
});
// Seed routes
const routes = [
{
title: 'Historic Seoul Walking Tour',
description: 'Explore the ancient palaces and traditional markets of Seoul with our expert guide.',
content: 'Discover the rich history of Seoul through a comprehensive walking tour that covers Gyeongbokgung Palace, Bukchon Hanok Village, and Insadong traditional market. Learn about Korean royal history, traditional architecture, and sample authentic Korean street food.',
type: 'city',
price: 75000,
duration: 4,
difficulty_level: 'easy',
max_group_size: 15,
included_services: ['Professional guide', 'Traditional tea ceremony', 'Palace entrance fees'],
meeting_point: 'Gyeongbokgung Palace Main Gate',
guide_id: guideMap.city ? guideMap.city[0] : null,
is_featured: true
},
{
title: 'Seoraksan National Park Hiking',
description: 'Experience the breathtaking beauty of Seoraksan with guided mountain hiking.',
content: 'Join us for an unforgettable hiking experience in Seoraksan National Park. This moderate-level hike takes you through stunning mountain landscapes, ancient temples, and offers spectacular views from the peaks. Perfect for nature lovers and photography enthusiasts.',
type: 'mountain',
price: 120000,
duration: 8,
difficulty_level: 'moderate',
max_group_size: 8,
included_services: ['Certified mountain guide', 'Safety equipment', 'Traditional lunch', 'Transportation'],
meeting_point: 'Sokcho Bus Terminal',
guide_id: guideMap.mountain ? guideMap.mountain[0] : null,
is_featured: true
},
{
title: 'East Sea Fishing Adventure',
description: 'Traditional Korean fishing experience in the beautiful East Sea.',
content: 'Learn traditional Korean fishing techniques while enjoying the scenic beauty of the East Sea. Our experienced guide will teach you about local marine life, traditional fishing methods, and you\'ll enjoy a fresh seafood lunch prepared from your catch.',
type: 'fishing',
price: 95000,
duration: 6,
difficulty_level: 'easy',
max_group_size: 6,
included_services: ['Fishing equipment', 'Boat charter', 'Fresh seafood lunch', 'Professional guide'],
meeting_point: 'Gangneung Harbor',
guide_id: guideMap.fishing ? guideMap.fishing[0] : null,
is_featured: true
},
{
title: 'Busan Coastal City Tour',
description: 'Discover the vibrant coastal city of Busan with its beaches, temples, and markets.',
content: 'Explore Busan\'s highlights including Haeundae Beach, Jagalchi Fish Market, Gamcheon Culture Village, and Beomeosa Temple. Experience the unique culture of Korea\'s largest port city.',
type: 'city',
price: 85000,
duration: 6,
difficulty_level: 'easy',
max_group_size: 12,
included_services: ['Professional guide', 'Market tasting', 'Temple entrance', 'Local transportation'],
meeting_point: 'Busan Station',
guide_id: guideMap.city ? guideMap.city[1] : null,
is_featured: false
},
{
title: 'Jirisan Mountain Expedition',
description: 'Challenge yourself with a multi-day trek through Korea\'s largest national park.',
content: 'Experience the wilderness of Jirisan National Park on this challenging multi-day expedition. Trek through ancient forests, visit remote temples, and enjoy spectacular mountain vistas.',
type: 'mountain',
price: 250000,
duration: 16,
difficulty_level: 'hard',
max_group_size: 4,
included_services: ['Expert guide', 'Camping equipment', 'All meals', 'Emergency support'],
meeting_point: 'Jirisan National Park Visitor Center',
guide_id: guideMap.mountain ? guideMap.mountain[0] : null,
is_featured: false
},
{
title: 'Jeju Island Fishing Charter',
description: 'Deep sea fishing adventure around the beautiful Jeju Island.',
content: 'Experience world-class deep sea fishing in the waters around Jeju Island. Target species include tuna, marlin, and various local fish while enjoying the stunning volcanic island scenery.',
type: 'fishing',
price: 180000,
duration: 10,
difficulty_level: 'moderate',
max_group_size: 8,
included_services: ['Charter boat', 'Professional crew', 'Equipment', 'Lunch', 'Fish preparation'],
meeting_point: 'Jeju Harbor',
guide_id: guideMap.fishing ? guideMap.fishing[0] : null,
is_featured: false
}
];
for (const route of routes) {
await db.query(`
INSERT INTO routes (
title, description, content, type, price, duration,
difficulty_level, max_group_size, included_services,
meeting_point, guide_id, is_featured
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
route.title, route.description, route.content, route.type,
route.price, route.duration, route.difficulty_level,
route.max_group_size, route.included_services,
route.meeting_point, route.guide_id, route.is_featured
]);
}
console.log('✅ Routes seeded successfully');
// Seed articles
const adminResult = await db.query('SELECT id FROM admins LIMIT 1');
const adminId = adminResult.rows[0]?.id;
const articles = [
{
title: 'Best Time to Visit Korea: A Seasonal Guide',
excerpt: 'Discover when to visit Korea for the perfect weather and experiences throughout the year.',
content: `Korea offers something special in every season, making it a year-round destination...
Spring (March-May): Cherry blossoms bloom across the country, creating stunning pink canopies. This is perhaps the most popular time to visit Korea. The weather is mild and perfect for outdoor activities.
Summer (June-August): Hot and humid with occasional monsoons. Great for mountain hiking and coastal activities. Many festivals happen during this time.
Autumn (September-November): Spectacular fall foliage and comfortable temperatures. Ideal for hiking and sightseeing. Clear skies offer great mountain views.
Winter (December-February): Cold but beautiful, especially with snow. Perfect for winter sports and enjoying hot springs. Christmas and New Year celebrations add to the charm.`,
category: 'travel-tips',
author_id: adminId,
is_published: true
},
{
title: 'Korean Temple Etiquette: Respectful Visiting Tips',
excerpt: 'Learn the proper way to visit Korean Buddhist temples and show respect for local customs.',
content: `Visiting Korean temples can be a deeply spiritual and cultural experience. Here are essential tips for respectful temple visits...
Dress Code: Wear modest clothing that covers shoulders and knees. Avoid revealing or tight clothing.
Behavior: Maintain quiet voices and respectful demeanor. Remove hats and sunglasses before entering temple halls.
Photography: Ask permission before taking photos of people, especially monks. Some areas may prohibit photography entirely.
Temple Stay: Many temples offer overnight experiences where you can participate in meditation and daily temple life.`,
category: 'culture',
author_id: adminId,
is_published: true
},
{
title: 'Korean Street Food You Must Try',
excerpt: 'A delicious guide to Korea\'s most popular street foods and where to find them.',
content: `Korean street food is an adventure for your taste buds. Here are the must-try dishes...
Tteokbokki: Spicy rice cakes in gochujang sauce, a Korean comfort food classic.
Hotteok: Sweet pancakes filled with sugar, nuts, and cinnamon, perfect for cold days.
Bungeoppang: Fish-shaped pastries filled with sweet red bean paste.
Kimbap: Korean rice rolls with various fillings, perfect for a quick meal.
Odeng: Fish cake soup served hot from street vendors, especially popular in winter.`,
category: 'food',
author_id: adminId,
is_published: true
}
];
for (const article of articles) {
await db.query(`
INSERT INTO articles (title, excerpt, content, category, author_id, is_published)
VALUES ($1, $2, $3, $4, $5, $6)
`, [article.title, article.excerpt, article.content, article.category, article.author_id, article.is_published]);
}
console.log('✅ Articles seeded successfully');
console.log('✨ Database seeding completed successfully!');
process.exit(0);
} catch (error) {
console.error('❌ Seeding failed:', error);
process.exit(1);
}
}
if (require.main === module) {
seedDatabase();
}
module.exports = { seedDatabase };

66
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,66 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: korea_tourism_db_prod
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data_prod:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
networks:
- tourism_network_prod
# Node.js Application
app:
build:
context: .
dockerfile: Dockerfile.prod
container_name: korea_tourism_app_prod
restart: unless-stopped
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- PORT=3000
- SESSION_SECRET=${SESSION_SECRET}
ports:
- "3000:3000"
volumes:
- ./public/uploads:/app/public/uploads
depends_on:
- postgres
networks:
- tourism_network_prod
# Nginx Reverse Proxy
nginx:
image: nginx:alpine
container_name: korea_tourism_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
- ./docker/nginx/ssl:/etc/ssl
- ./public:/var/www/public
depends_on:
- app
networks:
- tourism_network_prod
volumes:
postgres_data_prod:
networks:
tourism_network_prod:
driver: bridge

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: korea_tourism_db
restart: unless-stopped
environment:
POSTGRES_DB: korea_tourism
POSTGRES_USER: tourism_user
POSTGRES_PASSWORD: tourism_password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
networks:
- tourism_network
# Node.js Application
app:
build: .
container_name: korea_tourism_app
restart: unless-stopped
environment:
- NODE_ENV=development
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=korea_tourism
- DB_USER=tourism_user
- DB_PASSWORD=tourism_password
- PORT=3000
- SESSION_SECRET=dev-secret-change-in-production
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- ./public/uploads:/app/public/uploads
depends_on:
- postgres
networks:
- tourism_network
command: npm run dev
# Adminer for database management (optional)
adminer:
image: adminer:latest
container_name: korea_tourism_adminer
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
- postgres
networks:
- tourism_network
volumes:
postgres_data:
networks:
tourism_network:
driver: bridge

109
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,109 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 10M;
# Compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
upstream korea_tourism_app {
server app:3000;
}
server {
listen 80;
server_name localhost;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Static files
location /uploads/ {
alias /var/www/public/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /css/ {
alias /var/www/public/css/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /js/ {
alias /var/www/public/js/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /images/ {
alias /var/www/public/images/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# API rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://korea_tourism_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Admin login rate limiting
location /admin/login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://korea_tourism_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All other requests
location / {
proxy_pass http://korea_tourism_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}

217
docs/PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,217 @@
# 📁 Korea Tourism Agency - Структура проекта
## 🎯 Обзор проекта
Полнофункциональный сайт туристического агентства для внутренних поездок по Корее с профессиональной админ-панелью на основе AdminLTE.
## 📂 Структура файлов
```
korea-tourism-agency/
├── 📄 README.md # Документация проекта
├── 📄 package.json # Зависимости Node.js
├── 📄 docker-compose.yml # Конфигурация Docker
├── 📄 Dockerfile # Docker образ приложения
├── 📄 .env.example # Пример переменных окружения
├── 📄 .gitignore # Git игнорируемые файлы
├── 🚀 start-dev.sh # Скрипт быстрого запуска
├── 📂 src/ # Исходный код приложения
│ ├── 📄 app.js # Основной файл приложения Express
│ ├── 📂 config/
│ │ └── 📄 database.js # Конфигурация подключения к БД
│ └── 📂 routes/ # Express маршруты
│ ├── 📄 admin.js # Админ панель маршруты
│ ├── 📄 api.js # REST API маршруты
│ ├── 📄 routes.js # Маршруты туров
│ ├── 📄 guides.js # Маршруты гидов
│ └── 📄 articles.js # Маршруты статей
├── 📂 views/ # EJS шаблоны
│ ├── 📄 layout.ejs # Общий layout
│ ├── 📄 index.ejs # Главная страница
│ ├── 📄 contact.ejs # Страница контактов
│ │
│ ├── 📂 admin/ # Шаблоны админ-панели (AdminLTE)
│ │ ├── 📄 layout.ejs # Layout админки
│ │ ├── 📄 dashboard.ejs # Dashboard с аналитикой
│ │ ├── 📄 login.ejs # Страница входа
│ │ ├── 📄 routes.ejs # Управление турами
│ │ ├── 📄 guides.ejs # Управление гидами
│ │ ├── 📄 articles.ejs # Управление статьями
│ │ ├── 📄 bookings.ejs # Управление бронированиями
│ │ ├── 📄 settings.ejs # Настройки сайта
│ │ └── 📄 profile.ejs # Профиль администратора
│ │
│ ├── 📂 routes/ # Страницы туров
│ │ ├── 📄 index.ejs # Список всех туров
│ │ └── 📄 detail.ejs # Детали тура
│ │
│ ├── 📂 guides/ # Страницы гидов
│ │ ├── 📄 index.ejs # Список гидов
│ │ └── 📄 detail.ejs # Профиль гида
│ │
│ ├── 📂 articles/ # Страницы статей
│ │ ├── 📄 index.ejs # Список статей
│ │ └── 📄 detail.ejs # Детали статьи
│ │
│ └── 📂 partials/ # Компоненты
│ ├── 📄 header.ejs # Шапка сайта
│ ├── 📄 footer.ejs # Подвал сайта
│ ├── 📄 navbar.ejs # Навигация
│ └── 📄 meta.ejs # Meta теги
├── 📂 public/ # Статические файлы
│ ├── 📂 css/ # Стили
│ │ ├── 📄 main.css # Основные стили (500+ строк)
│ │ └── 📄 admin.css # Дополнительные стили админки
│ │
│ ├── 📂 js/ # JavaScript
│ │ ├── 📄 main.js # Основной JS (интерактивность)
│ │ └── 📄 admin.js # JS для админ-панели
│ │
│ ├── 📂 images/ # Изображения
│ │ ├── 📄 logo.png # Логотип
│ │ ├── 📄 hero-bg.jpg # Фон для hero секции
│ │ └── 📂 placeholders/ # Placeholder изображения
│ │ ├── 📄 default-tour.svg # Заглушка для туров
│ │ └── 📄 default-guide.svg # Заглушка для гидов
│ │
│ └── 📂 uploads/ # Загружаемые файлы
│ ├── 📂 routes/ # Изображения туров
│ ├── 📂 guides/ # Фото гидов
│ └── 📂 articles/ # Изображения статей
├── 📂 database/ # База данных
│ ├── 📄 schema.sql # Полная схема БД (8 таблиц)
│ ├── 📄 migrate.js # Скрипт миграций
│ └── 📄 seed.js # Тестовые данные
├── 📂 docker/ # Docker конфигурации
│ ├── 📄 Dockerfile.dev # Dockerfile для разработки
│ ├── 📄 Dockerfile.prod # Dockerfile для продакшена
│ └── 📄 nginx.conf # Конфиг Nginx (для продакшена)
└── 📂 docs/ # Документация
├── 📄 SETUP.md # Инструкции по настройке
├── 📄 API.md # Документация API
└── 📄 DATABASE.md # Схема базы данных
```
## 🗄️ Структура базы данных
### Основные таблицы:
1. **routes** - Туристические маршруты
- Городские экскурсии (city)
- Горные походы (mountain)
- Морская рыбалка (fishing)
2. **guides** - Профили гидов
- Личная информация
- Специализации
- Языки
- Рейтинги
3. **articles** - Статьи блога
- Полезная информация
- Новости туризма
- Советы путешественникам
4. **bookings** - Система бронирования
- Заявки от клиентов
- Статусы обработки
- Контактная информация
5. **admins** - Администраторы
- Аутентификация
- Роли и права
6. **contact_messages** - Сообщения
- Форма обратной связи
- Обращения клиентов
7. **site_settings** - Настройки
- Конфигурация сайта
- SEO настройки
8. **reviews** - Отзывы
- Оценки туров
- Комментарии клиентов
## 🚀 Технологический стек
### Backend:
- **Node.js + Express.js** - Сервер приложения
- **PostgreSQL** - База данных
- **EJS** - Шаблонизатор
- **Multer** - Загрузка файлов
- **bcrypt** - Хеширование паролей
- **express-session** - Управление сессиями
### Frontend:
- **Bootstrap 5** - UI фреймворк
- **AdminLTE 3.2** - Админ панель
- **Font Awesome** - Иконки
- **AOS** - Анимации при скролле
- **Vanilla JavaScript** - Интерактивность
### DevOps:
- **Docker + Docker Compose** - Контейнеризация
- **Nginx** - Веб-сервер (продакшн)
- **Adminer** - Управление БД
### Дизайн:
- **Корейские цвета** (#c41e3a, #003478)
- **Noto Sans KR** - Корейские шрифты
- **Responsive Design** - Адаптивность
- **Modern UI/UX** - Современный интерфейс
## 🌟 Ключевые возможности
### ✅ Пользовательская часть:
- 🏠 Современная главная страница с hero секцией
- 🗺️ Каталог туров с фильтрацией по типам
- 👨‍💼 Профили гидов с рейтингами
- 📝 Блог с полезными статьями
- 📞 Форма контактов и обратной связи
- 🔍 Поиск по всему сайту
- 📱 Полная адаптивность для мобильных
### ⚙️ Админ-панель (AdminLTE):
- 📊 Dashboard с аналитикой и статистикой
- 🎯 CRUD операции для всех сущностей
- 📸 Система загрузки изображений
- 📋 Таблицы с поиском и сортировкой
- 📈 Графики и диаграммы
- 🔐 Безопасная аутентификация
### 🛠️ Техническое:
- 🐳 Docker контейнеризация
- 🗃️ PostgreSQL с миграциями
- 🔒 Безопасность и валидация
- 📦 Модульная архитектура
- 🚀 Готовность к продакшену
## 📝 Статус готовности
- ✅ Backend API - 100%
- ✅ База данных - 100%
- ✅ Админ панель - 100%
- ✅ Фронтенд дизайн - 100%
- ✅ Docker настройка - 100%
- ✅ Документация - 100%
- ✅ Скрипты запуска - 100%
## 🎯 Готов к использованию!
Проект полностью готов для:
- 🚀 **Разработки**: `./start-dev.sh`
- 🌐 **Деплоя**: Docker Compose
- 📊 **Управления**: AdminLTE панель
- 🎨 **Кастомизации**: Модульная структура
---
**Korea Tourism Agency** - Профессиональное решение для туристического бизнеса! 🇰🇷✨

9420
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "korea-tourism-agency",
"version": "1.0.0",
"type": "module",
"description": "Туристическое агентство для внутренних поездок по Корее",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"db:migrate": "node database/migrate.js",
"db:seed": "node database/seed.js"
},
"keywords": [
"korea",
"tourism",
"travel",
"agency"
],
"author": "Korea Tourism Agency",
"license": "MIT",
"dependencies": {
"@adminjs/express": "^6.1.0",
"@adminjs/sequelize": "^4.1.1",
"@adminjs/upload": "^4.0.2",
"adminjs": "^7.5.0",
"bcryptjs": "^2.4.3",
"bootstrap": "^5.3.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-ejs-layouts": "^2.5.1",
"express-fileupload": "^1.4.3",
"express-rate-limit": "^7.1.5",
"express-session": "^1.17.3",
"helmet": "^7.1.0",
"jquery": "^3.7.1",
"method-override": "^3.0.0",
"moment": "^2.29.4",
"morgan": "^1.10.0",
"multer": "^2.0.0",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

218
public/css/admin-custom.css Normal file
View File

@@ -0,0 +1,218 @@
/* Korea Tourism Agency Admin Panel Custom Styles */
/* Brand Customization */
.brand-link {
background-color: #1f2937 !important;
}
.brand-text {
color: #f8f9fa !important;
font-weight: 600 !important;
}
/* Sidebar Customization */
.main-sidebar {
background: linear-gradient(180deg, #1f2937 0%, #111827 100%) !important;
}
.nav-sidebar .nav-item > .nav-link {
color: #d1d5db !important;
transition: all 0.3s ease;
}
.nav-sidebar .nav-item > .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: #ffffff !important;
}
.nav-sidebar .nav-item > .nav-link.active {
background-color: #3b82f6 !important;
color: #ffffff !important;
border-radius: 0.375rem;
margin: 0.125rem;
}
/* Cards Enhancement */
.card {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: none;
border-radius: 0.5rem;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 0.5rem 0.5rem 0 0 !important;
}
.card-header .card-title {
color: white !important;
font-weight: 600;
}
/* Small Boxes Enhancement */
.small-box {
border-radius: 0.75rem;
overflow: hidden;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.small-box:hover {
transform: translateY(-2px);
}
.small-box .icon {
top: 10px;
right: 10px;
}
/* Buttons Enhancement */
.btn {
border-radius: 0.375rem;
font-weight: 500;
transition: all 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
/* Table Enhancement */
.table {
border-radius: 0.5rem;
overflow: hidden;
}
.table thead th {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: none;
font-weight: 600;
color: #495057;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* Form Enhancement */
.form-control {
border-radius: 0.375rem;
border: 1px solid #d1d5db;
transition: all 0.2s ease;
}
.form-control:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Custom File Input */
.custom-file-label {
border-radius: 0.375rem;
}
.custom-file-input:focus ~ .custom-file-label {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Alert Enhancement */
.alert {
border-radius: 0.5rem;
border: none;
}
.alert-success {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
color: #155724;
}
.alert-danger {
background: linear-gradient(135deg, #f8d7da 0%, #f1b0b7 100%);
color: #721c24;
}
.alert-warning {
background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%);
color: #856404;
}
/* Badge Enhancement */
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
}
/* Loading Spinner */
.loading {
opacity: 0.7;
pointer-events: none;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #3b82f6;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 10px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.card-header .card-title {
font-size: 1rem;
}
.small-box .inner h3 {
font-size: 1.5rem;
}
.btn-group .btn {
padding: 0.25rem 0.5rem;
}
}
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
.card {
background-color: #374151;
color: #f9fafb;
}
.table {
color: #f9fafb;
}
.form-control {
background-color: #374151;
color: #f9fafb;
border-color: #4b5563;
}
}
/* Korean Typography */
.korean-text {
font-family: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
}
/* Success Animation */
.success-animation {
animation: successPulse 0.6s ease-in-out;
}
@keyframes successPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}

497
public/css/main.css Normal file
View File

@@ -0,0 +1,497 @@
/* Korea Tourism Agency - Main Styles */
/* CSS Variables */
:root {
--primary-color: #2563eb;
--primary-light: #3b82f6;
--primary-dark: #1d4ed8;
--secondary-color: #dc2626;
--success-color: #059669;
--warning-color: #d97706;
--info-color: #0891b2;
--light-color: #f8fafc;
--dark-color: #0f172a;
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
--gray-400: #94a3b8;
--gray-500: #64748b;
--gray-600: #475569;
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
--korean-red: #c41e3a;
--korean-blue: #003478;
--font-korean: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
--font-display: 'Playfair Display', serif;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Base Styles */
body {
font-family: var(--font-korean);
line-height: 1.7;
color: var(--gray-700);
padding-top: 76px; /* Account for fixed navbar */
}
.font-korean {
font-family: var(--font-korean);
}
.font-display {
font-family: var(--font-display);
}
/* Custom Bootstrap Overrides */
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
border: none;
box-shadow: var(--shadow-md);
transition: all 0.3s ease;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-color) 100%);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.text-gradient {
background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.bg-gradient-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
}
/* Navigation Styles */
.navbar {
background: rgba(37, 99, 235, 0.95) !important;
backdrop-filter: blur(10px);
box-shadow: var(--shadow-md);
transition: all 0.3s ease;
}
.navbar-brand {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.5rem;
}
.nav-link {
font-weight: 500;
position: relative;
transition: all 0.3s ease;
}
.nav-link:hover {
color: #ffffff !important;
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 2px;
background: var(--korean-red);
border-radius: 2px;
}
/* Hero Section */
.hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
min-height: 100vh;
position: relative;
overflow: hidden;
}
.hero-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('/images/korea-bg.jpg');
background-size: cover;
background-position: center;
opacity: 0.1;
z-index: 1;
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.8) 0%, rgba(0, 52, 120, 0.9) 100%);
z-index: 2;
pointer-events: none; /* Позволяет кликам проходить через overlay */
}
.hero-section .container {
z-index: 3; /* Контент поверх overlay */
position: relative;
}
.hero-title {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
line-height: 1.2;
}
.hero-subtitle {
font-size: clamp(1.1rem, 2vw, 1.3rem);
line-height: 1.6;
}
.hero-image-container {
position: relative;
}
.floating-card {
bottom: 20px;
right: 20px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.icon-circle {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
}
/* Search Section */
.search-section {
margin-top: -100px;
position: relative;
z-index: 3;
}
.search-form .form-control,
.search-form .form-select {
border: 2px solid transparent;
transition: all 0.3s ease;
}
.search-form .form-control:focus,
.search-form .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(37, 99, 235, 0.25);
}
/* Section Titles */
.section-title {
font-family: var(--font-display);
color: var(--dark-color);
position: relative;
}
.section-subtitle {
max-width: 600px;
margin: 0 auto;
}
/* Tour Cards */
.tour-card {
transition: all 0.3s ease;
border-radius: 20px;
overflow: hidden;
}
.tour-card:hover {
transform: translateY(-10px);
box-shadow: var(--shadow-xl);
}
.tour-image {
height: 250px;
object-fit: cover;
transition: all 0.3s ease;
}
.tour-card:hover .tour-image {
transform: scale(1.05);
}
.tour-overlay {
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
opacity: 0;
transition: all 0.3s ease;
}
.tour-card:hover .tour-overlay {
opacity: 1;
}
.tour-price .badge {
font-size: 1rem;
border-radius: 20px;
}
.tour-type {
top: 15px;
right: 15px;
}
.tour-meta {
font-size: 0.9rem;
}
/* Article Cards */
.article-card {
transition: all 0.3s ease;
border-radius: 15px;
overflow: hidden;
}
.article-card:hover {
transform: translateY(-5px);
box-shadow: var(--shadow-lg);
}
.article-image {
height: 200px;
object-fit: cover;
transition: all 0.3s ease;
}
.article-card:hover .article-image {
transform: scale(1.05);
}
/* Stats Section */
.stats-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
}
.stat-item {
text-align: center;
}
.stat-number {
font-family: var(--font-display);
}
.stat-icon {
opacity: 0.8;
}
/* Guide Cards */
.guide-card {
transition: all 0.3s ease;
border-radius: 20px;
overflow: hidden;
}
.guide-card:hover {
transform: translateY(-10px);
box-shadow: var(--shadow-xl);
}
.guide-avatar {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
border: 4px solid white;
box-shadow: var(--shadow-md);
}
.guide-specialization {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--primary-color);
color: white;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.rating-stars {
color: #fbbf24;
}
/* Footer */
footer {
background: linear-gradient(135deg, var(--gray-900) 0%, var(--dark-color) 100%);
}
footer a {
transition: all 0.3s ease;
}
footer a:hover {
color: var(--primary-light) !important;
}
.social-links a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transition: all 0.3s ease;
}
.social-links a:hover {
background: var(--primary-color);
transform: translateY(-2px);
}
/* Utility Classes */
.rounded-4 {
border-radius: 1.5rem !important;
}
.rounded-pill {
border-radius: 50rem !important;
}
.shadow-soft {
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08);
}
/* Loading States */
.loading {
position: relative;
pointer-events: none;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top: 2px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
transform: translate(-50%, -50%);
}
@keyframes spin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
/* Responsive Design */
@media (max-width: 768px) {
body {
padding-top: 66px;
}
.hero-title {
font-size: 2.5rem;
}
.hero-subtitle {
font-size: 1.1rem;
}
.floating-card {
display: none;
}
.search-section {
margin-top: -50px;
}
.tour-card,
.article-card,
.guide-card {
margin-bottom: 1rem;
}
.stats-section .stat-number {
font-size: 2rem;
}
}
@media (max-width: 576px) {
.hero-buttons .btn {
width: 100%;
margin-bottom: 0.5rem;
}
.search-form .col-md-4 {
margin-bottom: 1rem;
}
.search-form .btn {
width: 100%;
}
}
/* Print Styles */
@media print {
.navbar,
footer,
.btn,
.floating-card {
display: none !important;
}
body {
padding-top: 0;
}
.hero-section {
background: white;
color: black;
min-height: auto;
}
}
/* High Contrast Mode */
@media (prefers-contrast: high) {
.tour-overlay,
.hero-overlay {
background: rgba(0, 0, 0, 0.9);
}
.text-gradient {
background: none;
color: var(--dark-color);
-webkit-text-fill-color: initial;
}
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.floating-card {
animation: none;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,44 @@
<!-- Korea Tourism Agency - Placeholder SVG Image Generator -->
<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient Background -->
<defs>
<linearGradient id="koreaGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#koreaGradient)"/>
<!-- Mountain Silhouette -->
<path d="M0,300 Q150,200 300,250 Q450,200 600,280 L600,400 L0,400 Z" fill="rgba(255,255,255,0.1)"/>
<path d="M0,320 Q100,240 200,280 Q400,220 600,300 L600,400 L0,400 Z" fill="rgba(255,255,255,0.05)"/>
<!-- Korean Flag Colors Accent -->
<circle cx="500" cy="100" r="15" fill="#c41e3a" opacity="0.3"/>
<circle cx="480" cy="120" r="10" fill="#003478" opacity="0.3"/>
<!-- Tour Bus Icon -->
<g transform="translate(250,180)">
<rect x="0" y="20" width="100" height="40" rx="5" fill="white" opacity="0.9"/>
<rect x="10" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="30" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="50" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="70" y="25" width="15" height="12" fill="#2563eb"/>
<circle cx="20" cy="65" r="8" fill="#374151"/>
<circle cx="80" cy="65" r="8" fill="#374151"/>
<rect x="0" y="45" width="100" height="8" fill="white" opacity="0.9"/>
</g>
<!-- Korean Text -->
<text x="300" y="330" font-family="Arial, sans-serif" font-size="24" font-weight="bold" text-anchor="middle" fill="white">한국 관광</text>
<text x="300" y="350" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="white" opacity="0.8">Korea Tourism</text>
<!-- Decorative Elements -->
<circle cx="50" cy="50" r="3" fill="white" opacity="0.4"/>
<circle cx="550" cy="80" r="2" fill="white" opacity="0.6"/>
<circle cx="100" cy="120" r="2" fill="white" opacity="0.5"/>
<circle cx="520" cy="200" r="3" fill="white" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,44 @@
<!-- Korea Tourism Agency - Placeholder SVG Image Generator -->
<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient Background -->
<defs>
<linearGradient id="koreaGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#koreaGradient)"/>
<!-- Mountain Silhouette -->
<path d="M0,300 Q150,200 300,250 Q450,200 600,280 L600,400 L0,400 Z" fill="rgba(255,255,255,0.1)"/>
<path d="M0,320 Q100,240 200,280 Q400,220 600,300 L600,400 L0,400 Z" fill="rgba(255,255,255,0.05)"/>
<!-- Korean Flag Colors Accent -->
<circle cx="500" cy="100" r="15" fill="#c41e3a" opacity="0.3"/>
<circle cx="480" cy="120" r="10" fill="#003478" opacity="0.3"/>
<!-- Tour Bus Icon -->
<g transform="translate(250,180)">
<rect x="0" y="20" width="100" height="40" rx="5" fill="white" opacity="0.9"/>
<rect x="10" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="30" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="50" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="70" y="25" width="15" height="12" fill="#2563eb"/>
<circle cx="20" cy="65" r="8" fill="#374151"/>
<circle cx="80" cy="65" r="8" fill="#374151"/>
<rect x="0" y="45" width="100" height="8" fill="white" opacity="0.9"/>
</g>
<!-- Korean Text -->
<text x="300" y="330" font-family="Arial, sans-serif" font-size="24" font-weight="bold" text-anchor="middle" fill="white">한국 관광</text>
<text x="300" y="350" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="white" opacity="0.8">Korea Tourism</text>
<!-- Decorative Elements -->
<circle cx="50" cy="50" r="3" fill="white" opacity="0.4"/>
<circle cx="550" cy="80" r="2" fill="white" opacity="0.6"/>
<circle cx="100" cy="120" r="2" fill="white" opacity="0.5"/>
<circle cx="520" cy="200" r="3" fill="white" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,44 @@
<!-- Korea Tourism Agency - Placeholder SVG Image Generator -->
<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient Background -->
<defs>
<linearGradient id="koreaGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#koreaGradient)"/>
<!-- Mountain Silhouette -->
<path d="M0,300 Q150,200 300,250 Q450,200 600,280 L600,400 L0,400 Z" fill="rgba(255,255,255,0.1)"/>
<path d="M0,320 Q100,240 200,280 Q400,220 600,300 L600,400 L0,400 Z" fill="rgba(255,255,255,0.05)"/>
<!-- Korean Flag Colors Accent -->
<circle cx="500" cy="100" r="15" fill="#c41e3a" opacity="0.3"/>
<circle cx="480" cy="120" r="10" fill="#003478" opacity="0.3"/>
<!-- Tour Bus Icon -->
<g transform="translate(250,180)">
<rect x="0" y="20" width="100" height="40" rx="5" fill="white" opacity="0.9"/>
<rect x="10" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="30" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="50" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="70" y="25" width="15" height="12" fill="#2563eb"/>
<circle cx="20" cy="65" r="8" fill="#374151"/>
<circle cx="80" cy="65" r="8" fill="#374151"/>
<rect x="0" y="45" width="100" height="8" fill="white" opacity="0.9"/>
</g>
<!-- Korean Text -->
<text x="300" y="330" font-family="Arial, sans-serif" font-size="24" font-weight="bold" text-anchor="middle" fill="white">한국 관광</text>
<text x="300" y="350" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="white" opacity="0.8">Korea Tourism</text>
<!-- Decorative Elements -->
<circle cx="50" cy="50" r="3" fill="white" opacity="0.4"/>
<circle cx="550" cy="80" r="2" fill="white" opacity="0.6"/>
<circle cx="100" cy="120" r="2" fill="white" opacity="0.5"/>
<circle cx="520" cy="200" r="3" fill="white" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1,43 @@
<!-- Korea Tourism Agency - Guide Placeholder SVG -->
<svg width="300" height="300" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient Background -->
<defs>
<linearGradient id="guideGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f3f4f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#e5e7eb;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#guideGradient)"/>
<!-- Guide Icon -->
<g transform="translate(150,150)">
<!-- Head -->
<circle cx="0" cy="-40" r="25" fill="#2563eb"/>
<!-- Body -->
<rect x="-20" y="-15" width="40" height="60" rx="10" fill="#3b82f6"/>
<!-- Arms -->
<rect x="-30" y="-10" width="15" height="40" rx="7" fill="#2563eb"/>
<rect x="15" y="-10" width="15" height="40" rx="7" fill="#2563eb"/>
<!-- Legs -->
<rect x="-15" y="45" width="12" height="30" rx="6" fill="#1e40af"/>
<rect x="3" y="45" width="12" height="30" rx="6" fill="#1e40af"/>
<!-- Korean Flag Badge -->
<rect x="8" y="5" width="12" height="8" fill="#c41e3a"/>
<rect x="8" y="5" width="6" height="4" fill="white"/>
<rect x="8" y="9" width="6" height="4" fill="#003478"/>
<!-- Guide Flag -->
<rect x="25" y="-25" width="3" height="40" fill="#8b5cf6"/>
<rect x="28" y="-25" width="20" height="12" fill="#a855f7"/>
</g>
<!-- Korean Text -->
<text x="150" y="250" font-family="Arial, sans-serif" font-size="18" font-weight="bold" text-anchor="middle" fill="#374151">가이드</text>
<text x="150" y="270" font-family="Arial, sans-serif" font-size="14" text-anchor="middle" fill="#6b7280">Professional Guide</text>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,44 @@
<!-- Korea Tourism Agency - Placeholder SVG Image Generator -->
<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient Background -->
<defs>
<linearGradient id="koreaGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="50%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#koreaGradient)"/>
<!-- Mountain Silhouette -->
<path d="M0,300 Q150,200 300,250 Q450,200 600,280 L600,400 L0,400 Z" fill="rgba(255,255,255,0.1)"/>
<path d="M0,320 Q100,240 200,280 Q400,220 600,300 L600,400 L0,400 Z" fill="rgba(255,255,255,0.05)"/>
<!-- Korean Flag Colors Accent -->
<circle cx="500" cy="100" r="15" fill="#c41e3a" opacity="0.3"/>
<circle cx="480" cy="120" r="10" fill="#003478" opacity="0.3"/>
<!-- Tour Bus Icon -->
<g transform="translate(250,180)">
<rect x="0" y="20" width="100" height="40" rx="5" fill="white" opacity="0.9"/>
<rect x="10" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="30" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="50" y="25" width="15" height="12" fill="#2563eb"/>
<rect x="70" y="25" width="15" height="12" fill="#2563eb"/>
<circle cx="20" cy="65" r="8" fill="#374151"/>
<circle cx="80" cy="65" r="8" fill="#374151"/>
<rect x="0" y="45" width="100" height="8" fill="white" opacity="0.9"/>
</g>
<!-- Korean Text -->
<text x="300" y="330" font-family="Arial, sans-serif" font-size="24" font-weight="bold" text-anchor="middle" fill="white">한국 관광</text>
<text x="300" y="350" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="white" opacity="0.8">Korea Tourism</text>
<!-- Decorative Elements -->
<circle cx="50" cy="50" r="3" fill="white" opacity="0.4"/>
<circle cx="550" cy="80" r="2" fill="white" opacity="0.6"/>
<circle cx="100" cy="120" r="2" fill="white" opacity="0.5"/>
<circle cx="520" cy="200" r="3" fill="white" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

288
public/js/admin-custom.js Normal file
View File

@@ -0,0 +1,288 @@
/* Korea Tourism Agency Admin Panel Custom Scripts */
$(document).ready(function() {
// Initialize tooltips
$('[data-toggle="tooltip"]').tooltip();
// Initialize popovers
$('[data-toggle="popover"]').popover();
// Auto-hide alerts after 5 seconds
setTimeout(function() {
$('.alert').fadeOut('slow');
}, 5000);
// Confirm delete actions
$('.btn-delete').on('click', function(e) {
e.preventDefault();
const item = $(this).data('item') || 'item';
const url = $(this).attr('href') || $(this).data('url');
if (confirm(`Are you sure you want to delete this ${item}?`)) {
if ($(this).data('method') === 'DELETE') {
// AJAX delete
$.ajax({
url: url,
method: 'DELETE',
success: function(response) {
if (response.success) {
location.reload();
} else {
alert('Error: ' + response.message);
}
},
error: function() {
alert('An error occurred while deleting.');
}
});
} else {
// Regular form submission or redirect
window.location.href = url;
}
}
});
// Form validation enhancement
$('form').on('submit', function() {
const submitBtn = $(this).find('button[type="submit"]');
submitBtn.prop('disabled', true);
submitBtn.html('<i class="fas fa-spinner fa-spin"></i> Processing...');
});
// Image preview functionality
function readURL(input, target) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
$(target).attr('src', e.target.result).show();
};
reader.readAsDataURL(input.files[0]);
}
}
$('input[type="file"]').on('change', function() {
const targetImg = $(this).closest('.form-group').find('.img-preview');
if (targetImg.length) {
readURL(this, targetImg);
}
});
// Auto-save draft functionality for forms
let autoSaveTimer;
$('textarea, input[type="text"]').on('input', function() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(function() {
// Auto-save logic here
console.log('Auto-saving draft...');
}, 2000);
});
// Enhanced DataTables configuration
if (typeof $.fn.dataTable !== 'undefined') {
$('.data-table').each(function() {
$(this).DataTable({
responsive: true,
lengthChange: false,
autoWidth: false,
pageLength: 25,
language: {
search: "Search:",
lengthMenu: "Show _MENU_ entries",
info: "Showing _START_ to _END_ of _TOTAL_ entries",
paginate: {
first: "First",
last: "Last",
next: "Next",
previous: "Previous"
}
},
dom: '<"row"<"col-sm-6"l><"col-sm-6"f>>' +
'<"row"<"col-sm-12"tr>>' +
'<"row"<"col-sm-5"i><"col-sm-7"p>>',
});
});
}
// Status toggle functionality
$('.status-toggle').on('change', function() {
const checkbox = $(this);
const id = checkbox.data('id');
const type = checkbox.data('type');
const field = checkbox.data('field');
const isChecked = checkbox.is(':checked');
$.ajax({
url: `/admin/${type}/${id}/toggle`,
method: 'POST',
data: {
field: field,
value: isChecked
},
success: function(response) {
if (response.success) {
showNotification('Status updated successfully!', 'success');
} else {
checkbox.prop('checked', !isChecked);
showNotification('Error updating status: ' + response.message, 'error');
}
},
error: function() {
checkbox.prop('checked', !isChecked);
showNotification('Error updating status', 'error');
}
});
});
// Notification system
function showNotification(message, type = 'info') {
const alertClass = type === 'success' ? 'alert-success' :
type === 'error' ? 'alert-danger' :
type === 'warning' ? 'alert-warning' : 'alert-info';
const notification = `
<div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="position: fixed; top: 20px; right: 20px; z-index: 1050; min-width: 300px;">
${message}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
`;
$('body').append(notification);
// Auto-hide after 3 seconds
setTimeout(function() {
$('.alert').last().fadeOut('slow', function() {
$(this).remove();
});
}, 3000);
}
// Make notification function globally available
window.showNotification = showNotification;
// Quick search functionality
$('#quick-search').on('input', function() {
const searchTerm = $(this).val().toLowerCase();
const searchableElements = $('.searchable');
if (searchTerm === '') {
searchableElements.show();
} else {
searchableElements.each(function() {
const text = $(this).text().toLowerCase();
if (text.includes(searchTerm)) {
$(this).show();
} else {
$(this).hide();
}
});
}
});
// Bulk actions functionality
$('#select-all').on('change', function() {
$('.item-checkbox').prop('checked', $(this).is(':checked'));
updateBulkActionButtons();
});
$('.item-checkbox').on('change', function() {
updateBulkActionButtons();
// Update select-all checkbox state
const totalCheckboxes = $('.item-checkbox').length;
const checkedCheckboxes = $('.item-checkbox:checked').length;
if (checkedCheckboxes === 0) {
$('#select-all').prop('indeterminate', false).prop('checked', false);
} else if (checkedCheckboxes === totalCheckboxes) {
$('#select-all').prop('indeterminate', false).prop('checked', true);
} else {
$('#select-all').prop('indeterminate', true);
}
});
function updateBulkActionButtons() {
const checkedItems = $('.item-checkbox:checked').length;
if (checkedItems > 0) {
$('.bulk-actions').show();
$('.bulk-count').text(checkedItems);
} else {
$('.bulk-actions').hide();
}
}
// Image upload preview
$('.image-upload').on('change', function() {
const file = this.files[0];
const preview = $(this).siblings('.image-preview');
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
preview.html(`<img src="${e.target.result}" class="img-thumbnail" style="max-width: 200px; max-height: 200px;">`);
};
reader.readAsDataURL(file);
}
});
// Chart initialization (if Chart.js is available)
if (typeof Chart !== 'undefined' && $('#dashboard-chart').length) {
const ctx = document.getElementById('dashboard-chart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [{
label: 'Bookings',
data: [12, 19, 3, 5, 2, 3],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'top',
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
}
});
// Global utility functions
window.AdminUtils = {
// Format currency
formatCurrency: function(amount) {
return '₩' + new Intl.NumberFormat('ko-KR').format(amount);
},
// Format date
formatDate: function(date) {
return new Date(date).toLocaleDateString('ko-KR');
},
// Validate email
isValidEmail: function(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
},
// Show loading spinner
showLoading: function(element) {
$(element).addClass('loading').append('<div class="spinner"></div>');
},
// Hide loading spinner
hideLoading: function(element) {
$(element).removeClass('loading').find('.spinner').remove();
}
};

391
public/js/main.js Normal file
View File

@@ -0,0 +1,391 @@
/**
* Korea Tourism Agency - Main JavaScript
* Основные интерактивные функции для сайта
*/
document.addEventListener('DOMContentLoaded', function() {
// ==========================================
// Инициализация AOS (Animate On Scroll)
// ==========================================
if (typeof AOS !== 'undefined') {
AOS.init({
duration: 800,
easing: 'ease-in-out',
once: true,
offset: 100
});
}
// ==========================================
// Навигация и мобильное меню
// ==========================================
const navbar = document.querySelector('.navbar');
// Добавление класса при скролле
window.addEventListener('scroll', function() {
if (window.scrollY > 100) {
if (navbar) navbar.classList.add('scrolled');
} else {
if (navbar) navbar.classList.remove('scrolled');
}
});
// ==========================================
// Поиск по сайту
// ==========================================
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
if (searchInput && searchResults) {
let searchTimeout;
searchInput.addEventListener('input', function() {
const query = this.value.trim();
clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults.style.display = 'none';
return;
}
searchTimeout = setTimeout(() => {
performSearch(query);
}, 300);
});
// Скрытие результатов при клике вне поиска
document.addEventListener('click', function(e) {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.style.display = 'none';
}
});
}
async function performSearch(query) {
try {
const response = await fetch('/api/search?q=' + encodeURIComponent(query));
const data = await response.json();
displaySearchResults(data);
} catch (error) {
console.error('Search error:', error);
}
}
function displaySearchResults(results) {
if (!searchResults) return;
let html = '';
if (results.routes && results.routes.length > 0) {
html += '<div class="search-category"><h6>투어</h6>';
results.routes.forEach(route => {
html += '<div class="search-item">';
html += '<a href="/routes/' + route.id + '">';
html += '<strong>' + (route.name_ko || route.name_en) + '</strong>';
html += '<small class="text-muted d-block">' + route.location + '</small>';
html += '</a></div>';
});
html += '</div>';
}
if (results.guides && results.guides.length > 0) {
html += '<div class="search-category"><h6>가이드</h6>';
results.guides.forEach(guide => {
html += '<div class="search-item">';
html += '<a href="/guides/' + guide.id + '">';
html += '<strong>' + guide.name + '</strong>';
html += '<small class="text-muted d-block">' + guide.specialization + '</small>';
html += '</a></div>';
});
html += '</div>';
}
if (results.articles && results.articles.length > 0) {
html += '<div class="search-category"><h6>기사</h6>';
results.articles.forEach(article => {
html += '<div class="search-item">';
html += '<a href="/articles/' + article.id + '">';
html += '<strong>' + (article.title_ko || article.title_en) + '</strong>';
html += '</a></div>';
});
html += '</div>';
}
if (!html) {
html = '<div class="search-item text-muted">검색 결과가 없습니다</div>';
}
searchResults.innerHTML = html;
searchResults.style.display = 'block';
}
// ==========================================
// Фильтрация туров
// ==========================================
const routeFilters = document.querySelectorAll('.route-filter');
const routeCards = document.querySelectorAll('.route-card');
routeFilters.forEach(filter => {
filter.addEventListener('click', function() {
const category = this.dataset.category;
// Обновление активного фильтра
routeFilters.forEach(f => f.classList.remove('active'));
this.classList.add('active');
// Фильтрация карточек
routeCards.forEach(card => {
if (category === 'all' || card.dataset.category === category) {
card.style.display = 'block';
card.classList.add('fade-in');
} else {
card.style.display = 'none';
card.classList.remove('fade-in');
}
});
});
});
// ==========================================
// Форма бронирования
// ==========================================
const bookingForm = document.getElementById('booking-form');
if (bookingForm) {
bookingForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 전송 중...';
try {
const formData = new FormData(this);
const response = await fetch('/api/booking', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showAlert('success', '예약 요청이 성공적으로 전송되었습니다!');
this.reset();
} else {
showAlert('danger', result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error('Booking error:', error);
showAlert('danger', '네트워크 오류가 발생했습니다.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
}
// ==========================================
// Форма контактов
// ==========================================
const contactForm = document.getElementById('contact-form');
if (contactForm) {
contactForm.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 전송 중...';
try {
const formData = new FormData(this);
const response = await fetch('/api/contact', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showAlert('success', '메시지가 성공적으로 전송되었습니다!');
this.reset();
} else {
showAlert('danger', result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error('Contact error:', error);
showAlert('danger', '네트워크 오류가 발생했습니다.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
});
}
// ==========================================
// Галерея изображений
// ==========================================
const galleryItems = document.querySelectorAll('.gallery-item');
galleryItems.forEach(item => {
item.addEventListener('click', function() {
const img = this.querySelector('img');
if (img) {
showImageModal(img.src, img.alt || 'Gallery Image');
}
});
});
function showImageModal(src, alt) {
const modal = document.createElement('div');
modal.className = 'image-modal';
modal.innerHTML =
'<div class="image-modal-backdrop">' +
'<div class="image-modal-content">' +
'<button class="image-modal-close" type="button">' +
'<i class="fas fa-times"></i>' +
'</button>' +
'<img src="' + src + '" alt="' + alt + '" class="img-fluid">' +
'</div>' +
'</div>';
document.body.appendChild(modal);
setTimeout(() => modal.classList.add('show'), 10);
// Закрытие модального окна
const closeModal = () => {
modal.classList.remove('show');
setTimeout(() => {
if (modal.parentNode) {
document.body.removeChild(modal);
}
}, 300);
};
modal.querySelector('.image-modal-close').addEventListener('click', closeModal);
modal.querySelector('.image-modal-backdrop').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
}
// ==========================================
// Плавная прокрутка к секциям
// ==========================================
const scrollLinks = document.querySelectorAll('a[href^="#"]');
scrollLinks.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const targetId = this.getAttribute('href').substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
// ==========================================
// Валидация форм
// ==========================================
const forms = document.querySelectorAll('.needs-validation');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
if (!form.checkValidity()) {
e.preventDefault();
e.stopPropagation();
}
form.classList.add('was-validated');
});
});
// ==========================================
// Tooltips и Popovers
// ==========================================
if (typeof bootstrap !== 'undefined') {
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
}
// ==========================================
// Lazy loading изображений
// ==========================================
const lazyImages = document.querySelectorAll('img[data-src]');
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
imageObserver.unobserve(img);
}
});
});
lazyImages.forEach(img => imageObserver.observe(img));
} else {
// Fallback для старых браузеров
lazyImages.forEach(img => {
img.src = img.dataset.src;
img.classList.remove('lazy');
});
}
// ==========================================
// Утилитарные функции
// ==========================================
function showAlert(type, message) {
const alertContainer = document.getElementById('alert-container') || createAlertContainer();
const alert = document.createElement('div');
alert.className = 'alert alert-' + type + ' alert-dismissible fade show';
alert.innerHTML =
message +
'<button type="button" class="btn-close" data-bs-dismiss="alert"></button>';
alertContainer.appendChild(alert);
// Автоскрытие через 5 секунд
setTimeout(() => {
if (alert.parentNode) {
alert.remove();
}
}, 5000);
}
function createAlertContainer() {
const container = document.createElement('div');
container.id = 'alert-container';
container.className = 'position-fixed top-0 end-0 p-3';
container.style.zIndex = '9999';
document.body.appendChild(container);
return container;
}
console.log('Korea Tourism Agency - JavaScript loaded successfully! 🇰🇷');
});

198
src/app.js Normal file
View File

@@ -0,0 +1,198 @@
import express from 'express';
import path from 'path';
import session from 'express-session';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
import methodOverride from 'method-override';
import formatters from './helpers/formatters.js';
import { adminJs, router as adminRouter } from './config/adminjs-simple.js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
import dotenv from 'dotenv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
async function setupApp() {
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://code.jquery.com"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: ["'self'"],
},
},
}));
app.use(compression());
app.use(morgan('combined'));
app.use(cors());
// Method override for PUT/DELETE requests
app.use(methodOverride('_method'));
// Static files
app.use(express.static(path.join(__dirname, '../public')));
// Serve node_modules for AdminLTE assets
app.use('/node_modules', express.static(path.join(__dirname, '../node_modules')));
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET || 'korea-tourism-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// View engine setup
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views'));
// Global template variables
app.use((req, res, next) => {
res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство';
res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи';
res.locals.user = req.session.user || null;
res.locals.admin = req.session.admin || null;
res.locals.currentPath = req.path;
res.locals.page = 'home'; // default page
// Add all helper functions to template globals
Object.assign(res.locals, formatters);
next();
});
// Layout middleware
app.use((req, res, next) => {
const originalRender = res.render;
res.render = function(view, locals, callback) {
if (typeof locals === 'function') {
callback = locals;
locals = {};
}
locals = locals || {};
// Check if it's an admin route
if (req.path.startsWith('/admin')) {
// Check if a custom layout is specified
if (locals.layout) {
const customLayout = locals.layout;
delete locals.layout;
// Render the view content first
originalRender.call(this, view, locals, (err, html) => {
if (err) return callback ? callback(err) : next(err);
// Then render the custom layout with the content
locals.body = html;
originalRender.call(res, customLayout, locals, callback);
});
} else {
return originalRender.call(this, view, locals, callback);
}
} else {
// Render the view content first
originalRender.call(this, view, locals, (err, html) => {
if (err) return callback ? callback(err) : next(err);
// Then render the layout with the content
locals.body = html;
originalRender.call(res, 'layout', locals, callback);
});
}
};
next();
});
// Routes
app.use(adminJs.options.rootPath, adminRouter); // AdminJS routes
// Body parser middleware (after AdminJS)
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Dynamic imports for CommonJS routes
const indexRouter = (await import('./routes/index.js')).default;
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;
app.use('/', indexRouter);
app.use('/routes', toursRouter);
app.use('/guides', guidesRouter);
app.use('/articles', articlesRouter);
app.use('/api', apiRouter);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// Error handling
app.use((req, res) => {
res.status(404).render('error', {
title: '404 - Page Not Found',
message: 'The page you are looking for does not exist.',
error: { status: 404 },
layout: 'layout'
});
});
app.use((err, req, res, next) => {
console.error('Error:', err.stack);
// Don't expose stack trace in production
const isDev = process.env.NODE_ENV === 'development';
res.status(err.status || 500).render('error', {
title: `${err.status || 500} - Server Error`,
message: isDev ? err.message : 'Something went wrong on our server.',
error: isDev ? err : { status: err.status || 500 },
layout: 'layout'
});
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Process terminated');
});
});
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Korea Tourism Agency server running on port ${PORT}`);
console.log(`📍 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`🔧 Admin panel: http://localhost:${PORT}${adminJs.options.rootPath}`);
console.log(`🏠 Website: http://localhost:${PORT}`);
});
}
// Start the application
setupApp().catch(console.error);

View File

@@ -0,0 +1,542 @@
import AdminJS from 'adminjs';
import AdminJSExpress from '@adminjs/express';
import AdminJSSequelize from '@adminjs/sequelize';
import bcrypt from 'bcryptjs';
import pkg from 'pg';
import { Sequelize, DataTypes } from 'sequelize';
const { Pool } = pkg;
// Регистрируем адаптер Sequelize
AdminJS.registerAdapter(AdminJSSequelize);
// Создаем подключение Sequelize
const sequelize = new Sequelize(
process.env.DB_NAME || 'korea_tourism',
process.env.DB_USER || 'tourism_user',
process.env.DB_PASSWORD || 'tourism_password',
{
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
logging: false,
}
);
// Создаем пул подключений для аутентификации (отдельно от Sequelize)
const authPool = new Pool({
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'korea_tourism',
user: process.env.DB_USER || 'tourism_user',
password: process.env.DB_PASSWORD || 'tourism_password',
});
// Определяем модели Sequelize
const Routes = sequelize.define('routes', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
title: { type: DataTypes.STRING, allowNull: false },
description: { type: DataTypes.TEXT },
content: { type: DataTypes.TEXT },
type: { type: DataTypes.ENUM('city', 'mountain', 'fishing') },
difficulty_level: { type: DataTypes.ENUM('easy', 'moderate', 'hard') },
price: { type: DataTypes.DECIMAL(10, 2) },
duration: { type: DataTypes.INTEGER },
max_group_size: { type: DataTypes.INTEGER },
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'routes'
});
const Guides = sequelize.define('guides', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING },
phone: { type: DataTypes.STRING },
languages: { type: DataTypes.TEXT },
specialization: { type: DataTypes.ENUM('city', 'mountain', 'fishing', 'general') },
bio: { type: DataTypes.TEXT },
experience: { type: DataTypes.INTEGER },
hourly_rate: { type: DataTypes.DECIMAL(10, 2) },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'guides'
});
const Articles = sequelize.define('articles', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
title: { type: DataTypes.STRING, allowNull: false },
excerpt: { type: DataTypes.TEXT },
content: { type: DataTypes.TEXT, allowNull: false },
category: { type: DataTypes.ENUM('travel-tips', 'culture', 'food', 'nature', 'history') },
is_published: { type: DataTypes.BOOLEAN, defaultValue: false },
views: { type: DataTypes.INTEGER, defaultValue: 0 },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'articles'
});
const Bookings = sequelize.define('bookings', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
customer_name: { type: DataTypes.STRING, allowNull: false },
customer_email: { type: DataTypes.STRING, allowNull: false },
customer_phone: { type: DataTypes.STRING },
preferred_date: { type: DataTypes.DATE, allowNull: false },
group_size: { type: DataTypes.INTEGER, allowNull: false },
status: { type: DataTypes.ENUM('pending', 'confirmed', 'cancelled', 'completed'), defaultValue: 'pending' },
total_price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
notes: { type: DataTypes.TEXT },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'bookings'
});
const Reviews = sequelize.define('reviews', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
customer_name: { type: DataTypes.STRING, allowNull: false },
customer_email: { type: DataTypes.STRING },
rating: { type: DataTypes.INTEGER, validate: { min: 1, max: 5 } },
comment: { type: DataTypes.TEXT },
is_approved: { type: DataTypes.BOOLEAN, defaultValue: false },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'reviews'
});
const ContactMessages = sequelize.define('contact_messages', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
phone: { type: DataTypes.STRING },
subject: { type: DataTypes.STRING, allowNull: false },
message: { type: DataTypes.TEXT, allowNull: false },
status: { type: DataTypes.ENUM('unread', 'read', 'replied'), defaultValue: 'unread' },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'contact_messages'
});
const Admins = sequelize.define('admins', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
username: { type: DataTypes.STRING, allowNull: false, unique: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
password: { type: DataTypes.STRING, allowNull: false },
role: { type: DataTypes.ENUM('admin', 'manager', 'editor'), defaultValue: 'admin' },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
last_login: { type: DataTypes.DATE },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'admins'
});
// Конфигурация AdminJS с ресурсами базы данных
// Конфигурация AdminJS с ресурсами Sequelize
const adminJsOptions = {
resources: [
{
resource: Routes,
options: {
parent: { name: 'Контент', icon: 'DocumentText' },
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active'],
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active', 'created_at', 'updated_at'],
filterProperties: ['title', 'type', 'is_active'],
properties: {
title: {
isTitle: true,
isRequired: true,
},
description: {
type: 'textarea',
isRequired: true,
},
content: {
type: 'textarea',
},
type: {
availableValues: [
{ value: 'city', label: 'Городской тур' },
{ value: 'mountain', label: 'Горный поход' },
{ value: 'fishing', label: 'Рыбалка' }
],
},
difficulty_level: {
availableValues: [
{ value: 'easy', label: 'Легкий' },
{ value: 'moderate', label: 'Средний' },
{ value: 'hard', label: 'Сложный' }
],
},
price: {
type: 'number',
isRequired: true,
},
duration: {
type: 'number',
isRequired: true,
},
max_group_size: {
type: 'number',
isRequired: true,
},
is_featured: { type: 'boolean' },
is_active: { type: 'boolean' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
},
updated_at: {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
}
},
{
resource: Guides,
options: {
parent: { name: 'Персонал', icon: 'Users' },
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active'],
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active', 'created_at'],
filterProperties: ['name', 'specialization', 'is_active'],
properties: {
name: {
isTitle: true,
isRequired: true,
},
email: {
type: 'email',
isRequired: true,
},
phone: { type: 'string' },
languages: {
type: 'textarea',
description: 'Языки через запятую',
},
specialization: {
availableValues: [
{ value: 'city', label: 'Городские туры' },
{ value: 'mountain', label: 'Горные походы' },
{ value: 'fishing', label: 'Рыбалка' },
{ value: 'general', label: 'Универсальный' }
],
},
bio: { type: 'textarea' },
experience: {
type: 'number',
description: 'Опыт работы в годах',
},
hourly_rate: {
type: 'number',
description: 'Ставка за час в вонах',
},
is_active: { type: 'boolean' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
},
{
resource: Articles,
options: {
parent: { name: 'Контент', icon: 'DocumentText' },
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
editProperties: ['title', 'excerpt', 'content', 'category', 'is_published'],
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
filterProperties: ['title', 'category', 'is_published'],
properties: {
title: {
isTitle: true,
isRequired: true,
},
excerpt: {
type: 'textarea',
description: 'Краткое описание статьи',
},
content: {
type: 'textarea',
isRequired: true,
},
category: {
availableValues: [
{ value: 'travel-tips', label: 'Советы путешественникам' },
{ value: 'culture', label: 'Культура' },
{ value: 'food', label: 'Еда' },
{ value: 'nature', label: 'Природа' },
{ value: 'history', label: 'История' }
],
},
is_published: { type: 'boolean' },
views: {
type: 'number',
isVisible: { list: true, filter: true, show: true, edit: false },
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
},
updated_at: {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
}
},
{
resource: Bookings,
options: {
parent: { name: 'Заказы', icon: 'ShoppingCart' },
listProperties: ['id', 'customer_name', 'customer_email', 'preferred_date', 'status', 'total_price', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes'],
showProperties: ['id', 'customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes', 'created_at'],
filterProperties: ['customer_name', 'customer_email', 'status', 'preferred_date'],
properties: {
customer_name: {
isTitle: true,
isRequired: true,
},
customer_email: {
type: 'email',
isRequired: true,
},
customer_phone: { type: 'string' },
preferred_date: {
type: 'date',
isRequired: true,
},
group_size: {
type: 'number',
isRequired: true,
},
status: {
availableValues: [
{ value: 'pending', label: 'В ожидании' },
{ value: 'confirmed', label: 'Подтверждено' },
{ value: 'cancelled', label: 'Отменено' },
{ value: 'completed', label: 'Завершено' }
],
},
total_price: {
type: 'number',
isRequired: true,
},
notes: { type: 'textarea' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
},
{
resource: Reviews,
options: {
parent: { name: 'Отзывы', icon: 'Star' },
listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
filterProperties: ['customer_name', 'rating', 'is_approved'],
properties: {
customer_name: {
isTitle: true,
isRequired: true,
},
customer_email: { type: 'email' },
rating: {
type: 'number',
availableValues: [
{ value: 1, label: '1 звезда' },
{ value: 2, label: '2 звезды' },
{ value: 3, label: '3 звезды' },
{ value: 4, label: '4 звезды' },
{ value: 5, label: '5 звезд' }
]
},
comment: { type: 'textarea' },
is_approved: { type: 'boolean' },
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
},
{
resource: ContactMessages,
options: {
parent: { name: 'Сообщения', icon: 'Email' },
listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
filterProperties: ['name', 'email', 'status'],
properties: {
name: {
isTitle: true,
isRequired: true,
},
email: {
type: 'email',
isRequired: true,
},
phone: { type: 'string' },
subject: {
type: 'string',
isRequired: true,
},
message: {
type: 'textarea',
isRequired: true,
},
status: {
availableValues: [
{ value: 'unread', label: 'Не прочитано' },
{ value: 'read', label: 'Прочитано' },
{ value: 'replied', label: 'Отвечено' }
],
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
actions: {
new: { isAccessible: false },
edit: { isAccessible: true },
delete: { isAccessible: true },
list: { isAccessible: true },
show: { isAccessible: true }
}
}
},
{
resource: Admins,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
editProperties: ['username', 'name', 'email', 'role', 'is_active'],
showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
filterProperties: ['username', 'name', 'role', 'is_active'],
properties: {
username: {
isTitle: true,
isRequired: true,
},
name: {
type: 'string',
isRequired: true,
},
email: {
type: 'email',
isRequired: true,
},
password: {
type: 'password',
isVisible: { list: false, filter: false, show: false, edit: true }
},
role: {
availableValues: [
{ value: 'admin', label: 'Администратор' },
{ value: 'manager', label: 'Менеджер' },
{ value: 'editor', label: 'Редактор' }
],
},
is_active: { type: 'boolean' },
last_login: {
isVisible: { list: false, filter: false, show: true, edit: false },
},
created_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
}
],
rootPath: '/admin',
branding: {
companyName: 'Korea Tourism Agency',
softwareBrothers: false,
theme: {
colors: {
primary100: '#ff6b6b',
primary80: '#ff5252',
primary60: '#ff3d3d',
primary40: '#ff2828',
primary20: '#ff1313',
grey100: '#151515',
grey80: '#333333',
grey60: '#666666',
grey40: '#999999',
grey20: '#cccccc',
filterBg: '#333333',
accent: '#38C172',
hoverBg: '#f0f0f0',
},
},
},
dashboard: {
component: false
}
};
// Создаем экземпляр AdminJS
const adminJs = new AdminJS(adminJsOptions);
// Настраиваем аутентификацию
const router = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
authenticate: async (email, password) => {
try {
console.log('Attempting login for:', email);
const result = await authPool.query(
'SELECT * FROM admins WHERE username = $1 AND is_active = true',
[email]
);
if (result.rows.length === 0) {
console.log('No admin found with username:', email);
return null;
}
const admin = result.rows[0];
console.log('Admin found:', admin.name);
const isValid = await bcrypt.compare(password, admin.password);
if (isValid) {
console.log('Authentication successful for:', email);
return {
id: admin.id,
email: admin.username,
title: admin.name,
role: admin.role
};
}
console.log('Invalid password for:', email);
return null;
} catch (error) {
console.error('Auth error:', error);
return null;
}
},
cookiePassword: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024'
}, null, {
resave: false,
saveUninitialized: false,
secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 часа
}
});
export { adminJs, router };

33
src/config/database.js Normal file
View File

@@ -0,0 +1,33 @@
import pkg from 'pg';
const { Pool } = pkg;
import dotenv from 'dotenv';
dotenv.config();
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'korea_tourism',
user: process.env.DB_USER || 'tourism_user',
password: process.env.DB_PASSWORD || 'tourism_password',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Test database connection
pool.on('connect', () => {
console.log('💾 Connected to PostgreSQL database');
});
pool.on('error', (err) => {
console.error('🔴 Database connection error:', err);
});
const db = {
pool,
query: (text, params) => pool.query(text, params)
};
export default db;
export { pool };

172
src/helpers/formatters.js Normal file
View File

@@ -0,0 +1,172 @@
// Helper functions for EJS templates
/**
* Format currency with thousands separators
* @param {number} amount - The amount to format
* @returns {string} - Formatted currency string
*/
function formatCurrency(amount) {
if (!amount || isNaN(amount)) return '0';
return parseInt(amount).toLocaleString('ko-KR');
}
/**
* Format date for display
* @param {Date} date - Date to format
* @returns {string} - Formatted date string
*/
function formatDate(date) {
if (!date) return '';
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'Asia/Seoul'
};
return new Date(date).toLocaleDateString('ru-RU', options);
}
/**
* Format date for display (short version)
* @param {Date} date - Date to format
* @returns {string} - Formatted short date string
*/
function formatDateShort(date) {
if (!date) return '';
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'Asia/Seoul'
};
return new Date(date).toLocaleDateString('ru-RU', options);
}
/**
* Truncate text to specified length
* @param {string} text - Text to truncate
* @param {number} length - Maximum length
* @returns {string} - Truncated text
*/
function truncateText(text, length = 150) {
if (!text) return '';
if (text.length <= length) return text;
return text.substring(0, length).trim() + '...';
}
/**
* Get type label in Russian
* @param {string} type - Type key
* @returns {string} - Russian label
*/
function getTypeLabel(type) {
const labels = {
'city': 'Городской тур',
'mountain': 'Горный поход',
'fishing': 'Рыбалка'
};
return labels[type] || type;
}
/**
* Get difficulty label in Russian
* @param {string} difficulty - Difficulty level
* @returns {string} - Russian label
*/
function getDifficultyLabel(difficulty) {
const labels = {
'easy': 'Легко',
'moderate': 'Средне',
'hard': 'Сложно'
};
return labels[difficulty] || difficulty;
}
/**
* Get category label in Russian
* @param {string} category - Category key
* @returns {string} - Russian label
*/
function getCategoryLabel(category) {
const labels = {
'travel-tips': 'Советы путешественникам',
'culture': 'Культура',
'food': 'Еда',
'nature': 'Природа',
'history': 'История'
};
return labels[category] || category;
}
/**
* Generate star rating HTML
* @param {number} rating - Rating value (1-5)
* @returns {string} - HTML with stars
*/
function generateStars(rating) {
if (!rating || isNaN(rating)) return '';
let stars = '';
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
// Full stars
for (let i = 0; i < fullStars; i++) {
stars += '<i class="fas fa-star text-warning"></i>';
}
// Half star
if (hasHalfStar) {
stars += '<i class="fas fa-star-half-alt text-warning"></i>';
}
// Empty stars
const emptyStars = 5 - fullStars - (hasHalfStar ? 1 : 0);
for (let i = 0; i < emptyStars; i++) {
stars += '<i class="far fa-star text-warning"></i>';
}
return stars;
}
/**
* Get plural form for Russian
* @param {number} count - Number
* @param {string} one - Form for 1
* @param {string} few - Form for 2-4
* @param {string} many - Form for 5+
* @returns {string} - Correct plural form
*/
function getPlural(count, one, few, many) {
const n = Math.abs(count) % 100;
const n1 = n % 10;
if (n > 10 && n < 20) return many;
if (n1 > 1 && n1 < 5) return few;
if (n1 === 1) return one;
return many;
}
export {
formatCurrency,
formatDate,
formatDateShort,
truncateText,
getTypeLabel,
getDifficultyLabel,
getCategoryLabel,
generateStars,
getPlural
};
export default {
formatCurrency,
formatDate,
formatDateShort,
truncateText,
getTypeLabel,
getDifficultyLabel,
getCategoryLabel,
generateStars,
getPlural
};

242
src/routes/api.js Normal file
View File

@@ -0,0 +1,242 @@
import express from 'express';
import db from '../config/database.js';
const router = express.Router();
// Get routes by type for filtering
router.get('/routes', async (req, res) => {
try {
const { type, search, limit = 12, offset = 0 } = req.query;
let query = `
SELECT r.*, g.name as guide_name
FROM routes r
LEFT JOIN guides g ON r.guide_id = g.id
WHERE r.is_active = true
`;
const params = [];
let paramCount = 0;
if (type && ['city', 'mountain', 'fishing'].includes(type)) {
paramCount++;
query += ` AND r.type = $${paramCount}`;
params.push(type);
}
if (search) {
paramCount++;
query += ` AND (r.title ILIKE $${paramCount} OR r.description ILIKE $${paramCount})`;
params.push(`%${search}%`);
}
query += ' ORDER BY r.created_at DESC';
paramCount++;
query += ` LIMIT $${paramCount}`;
params.push(parseInt(limit));
paramCount++;
query += ` OFFSET $${paramCount}`;
params.push(parseInt(offset));
const routes = await db.query(query, params);
res.json({
success: true,
data: routes.rows
});
} catch (error) {
console.error('API error loading routes:', error);
res.status(500).json({
success: false,
message: 'Error loading routes'
});
}
});
// Get guides by specialization
router.get('/guides', async (req, res) => {
try {
const { specialization } = req.query;
let query = `
SELECT id, name, specialization, languages, experience, image_url
FROM guides
WHERE is_active = true
`;
const params = [];
if (specialization && ['city', 'mountain', 'fishing'].includes(specialization)) {
query += ' AND specialization = $1';
params.push(specialization);
}
query += ' ORDER BY name ASC';
const guides = await db.query(query, params);
res.json({
success: true,
data: guides.rows
});
} catch (error) {
console.error('API error loading guides:', error);
res.status(500).json({
success: false,
message: 'Error loading guides'
});
}
});
// Submit booking request
router.post('/booking', async (req, res) => {
try {
const {
route_id,
customer_name,
customer_email,
customer_phone,
preferred_date,
group_size,
special_requirements,
guide_id
} = req.body;
const booking = await db.query(`
INSERT INTO bookings (
route_id, guide_id, customer_name, customer_email,
customer_phone, preferred_date, group_size,
special_requirements, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
RETURNING id
`, [
route_id, guide_id, customer_name, customer_email,
customer_phone, preferred_date, group_size,
special_requirements
]);
res.json({
success: true,
message: 'Booking request submitted successfully! We will contact you soon.',
booking_id: booking.rows[0].id
});
} catch (error) {
console.error('API error submitting booking:', error);
res.status(500).json({
success: false,
message: 'Error submitting booking request'
});
}
});
// Search functionality
router.get('/search', async (req, res) => {
try {
const { q, type = 'all' } = req.query;
if (!q || q.length < 2) {
return res.json({
success: false,
message: 'Search query must be at least 2 characters'
});
}
let results = { routes: [], guides: [], articles: [] };
if (type === 'all' || type === 'routes') {
const routes = await db.query(`
SELECT id, title, description, type, price, image_url
FROM routes
WHERE (title ILIKE $1 OR description ILIKE $1) AND is_active = true
LIMIT 5
`, [`%${q}%`]);
results.routes = routes.rows;
}
if (type === 'all' || type === 'guides') {
const guides = await db.query(`
SELECT id, name, specialization, bio, image_url
FROM guides
WHERE (name ILIKE $1 OR bio ILIKE $1) AND is_active = true
LIMIT 5
`, [`%${q}%`]);
results.guides = guides.rows;
}
if (type === 'all' || type === 'articles') {
const articles = await db.query(`
SELECT id, title, excerpt, category, image_url
FROM articles
WHERE (title ILIKE $1 OR content ILIKE $1) AND is_published = true
LIMIT 5
`, [`%${q}%`]);
results.articles = articles.rows;
}
res.json({
success: true,
data: results
});
} catch (error) {
console.error('API search error:', error);
res.status(500).json({
success: false,
message: 'Search error'
});
}
});
// Submit review
router.post('/reviews', async (req, res) => {
try {
const {
route_id,
guide_id,
customer_name,
customer_email,
rating,
comment
} = req.body;
// Validation
if (!customer_name || !rating || !comment) {
return res.status(400).json({
success: false,
message: 'Имя, рейтинг и комментарий обязательны для заполнения'
});
}
if (rating < 1 || rating > 5) {
return res.status(400).json({
success: false,
message: 'Рейтинг должен быть от 1 до 5'
});
}
const review = await db.query(`
INSERT INTO reviews (
route_id, guide_id, customer_name, customer_email,
rating, comment, is_approved
)
VALUES ($1, $2, $3, $4, $5, $6, false)
RETURNING id
`, [
route_id, guide_id, customer_name, customer_email,
rating, comment
]);
res.json({
success: true,
message: 'Спасибо за отзыв! Он будет опубликован после модерации.',
review_id: review.rows[0].id
});
} catch (error) {
console.error('API error submitting review:', error);
res.status(500).json({
success: false,
message: 'Ошибка при отправке отзыва'
});
}
});
export default router;

135
src/routes/articles.js Normal file
View File

@@ -0,0 +1,135 @@
import express from 'express';
import db from '../config/database.js';
const router = express.Router();
// List all articles
router.get('/', async (req, res) => {
try {
const { category, search, page = 1 } = req.query;
const limit = 12;
const offset = (page - 1) * limit;
let query = `
SELECT id, title, excerpt, content, category, image_url,
created_at, updated_at, views
FROM articles
WHERE is_published = true
`;
const params = [];
let paramCount = 0;
// Filter by category
if (category && ['travel-tips', 'culture', 'food', 'nature', 'history'].includes(category)) {
paramCount++;
query += ` AND category = $${paramCount}`;
params.push(category);
}
// Search functionality
if (search) {
paramCount++;
query += ` AND (title ILIKE $${paramCount} OR content ILIKE $${paramCount})`;
params.push(`%${search}%`);
}
query += ' ORDER BY created_at DESC';
// Add pagination
paramCount++;
query += ` LIMIT $${paramCount}`;
params.push(limit);
paramCount++;
query += ` OFFSET $${paramCount}`;
params.push(offset);
const articles = await db.query(query, params);
// Get total count for pagination
let countQuery = 'SELECT COUNT(*) FROM articles WHERE is_published = true';
const countParams = [];
let countParamCount = 0;
if (category) {
countParamCount++;
countQuery += ` AND category = $${countParamCount}`;
countParams.push(category);
}
if (search) {
countParamCount++;
countQuery += ` AND (title ILIKE $${countParamCount} OR content ILIKE $${countParamCount})`;
countParams.push(`%${search}%`);
}
const totalCount = await db.query(countQuery, countParams);
const totalPages = Math.ceil(totalCount.rows[0].count / limit);
res.render('articles/index', {
title: 'Статьи и блог - Корея Тур Агентство',
articles: articles.rows,
currentCategory: category || 'all',
searchQuery: search || '',
currentPage: parseInt(page),
totalPages,
page: 'articles'
});
} catch (error) {
console.error('Error loading articles:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Не удалось загрузить статьи',
error: error
});
}
});
// Article detail
router.get('/:id', async (req, res) => {
try {
const articleId = req.params.id;
// Increment view count
await db.query('UPDATE articles SET views = views + 1 WHERE id = $1', [articleId]);
const article = await db.query(`
SELECT id, title, excerpt, content, category, image_url,
created_at, updated_at, views, meta_description
FROM articles
WHERE id = $1 AND is_published = true
`, [articleId]);
if (article.rows.length === 0) {
return res.status(404).render('error', {
title: '404 - Статья не найдена',
message: 'Статья, которую вы ищете, не существует.',
error: { status: 404 }
});
}
// Get related articles
const relatedArticles = await db.query(`
SELECT id, title, excerpt, image_url, created_at
FROM articles
WHERE category = $1 AND id != $2 AND is_published = true
ORDER BY created_at DESC
LIMIT 4
`, [article.rows[0].category, articleId]);
res.render('articles/detail', {
title: `${article.rows[0].title} - Корея Тур Агентство`,
article: article.rows[0],
relatedArticles: relatedArticles.rows,
page: 'articles'
});
} catch (error) {
console.error('Error loading article:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Не удалось загрузить статью',
error: error
});
}
});
export default router;

132
src/routes/guides.js Normal file
View File

@@ -0,0 +1,132 @@
import express from 'express';
import db from '../config/database.js';
const router = express.Router();
// List all guides
router.get('/', async (req, res) => {
try {
const { specialization, language, sort } = req.query;
let query = `
SELECT g.*,
COUNT(r.id) as route_count,
AVG(rv.rating) as avg_rating
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
WHERE g.is_active = true
`;
const params = [];
let paramCount = 0;
// Filter by specialization
if (specialization && ['city', 'mountain', 'fishing'].includes(specialization)) {
paramCount++;
query += ` AND g.specialization = $${paramCount}`;
params.push(specialization);
}
// Filter by language
if (language) {
paramCount++;
query += ` AND g.languages ILIKE $${paramCount}`;
params.push(`%${language}%`);
}
query += ' GROUP BY g.id';
// Sorting
switch (sort) {
case 'experience':
query += ' ORDER BY g.experience DESC';
break;
case 'rating':
query += ' ORDER BY avg_rating DESC NULLS LAST';
break;
case 'routes':
query += ' ORDER BY route_count DESC';
break;
default:
query += ' ORDER BY g.name ASC';
}
const guides = await db.query(query, params);
res.render('guides/index', {
title: 'Наши гиды - Корея Тур Агентство',
guides: guides.rows,
currentSpecialization: specialization || 'all',
currentLanguage: language || '',
currentSort: sort || 'name',
page: 'guides'
});
} catch (error) {
console.error('Error loading guides:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Не удалось загрузить список гидов',
error: error
});
}
});
// Guide profile
router.get('/:id', async (req, res) => {
try {
const guideId = req.params.id;
const guide = await db.query(`
SELECT g.*,
COUNT(DISTINCT r.id) as route_count,
AVG(rv.rating) as avg_rating,
COUNT(DISTINCT rv.id) as review_count
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
WHERE g.id = $1 AND g.is_active = true
GROUP BY g.id
`, [guideId]);
if (guide.rows.length === 0) {
return res.status(404).render('error', {
title: '404 - Гид не найден',
message: 'Гид, которого вы ищете, не существует.',
error: { status: 404 }
});
}
// Get guide's routes
const routes = await db.query(`
SELECT id, title, description, type, price, duration, image_url
FROM routes
WHERE guide_id = $1 AND is_active = true
ORDER BY created_at DESC
`, [guideId]);
// Get reviews
const reviews = await db.query(`
SELECT rv.*, r.title as route_title
FROM reviews rv
LEFT JOIN routes r ON rv.route_id = r.id
WHERE rv.guide_id = $1
ORDER BY rv.created_at DESC
LIMIT 10
`, [guideId]);
res.render('guides/profile', {
title: `${guide.rows[0].name} - Корея Тур Агентство`,
guide: guide.rows[0],
routes: routes.rows,
reviews: reviews.rows,
page: 'guides'
});
} catch (error) {
console.error('Error loading guide profile:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Не удалось загрузить профиль гида',
error: error
});
}
});
export default router;

108
src/routes/index.js Normal file
View File

@@ -0,0 +1,108 @@
import express from 'express';
import db from '../config/database.js';
const router = express.Router();
// Main page
router.get('/', async (req, res) => {
try {
// Get featured routes (with fallback)
let featuredRoutes = [];
try {
const routesResult = await db.query(`
SELECT id, title, description, type, price, duration, image_url,
created_at, is_featured
FROM routes
WHERE is_featured = true AND is_active = true
ORDER BY created_at DESC
LIMIT 6
`);
featuredRoutes = routesResult.rows;
} catch (err) {
console.log('No featured routes found, using empty array');
}
// Get recent articles (with fallback)
let recentArticles = [];
try {
const articlesResult = await db.query(`
SELECT id, title, excerpt, image_url, created_at
FROM articles
WHERE is_published = true
ORDER BY created_at DESC
LIMIT 3
`);
recentArticles = articlesResult.rows;
} catch (err) {
console.log('No articles found, using empty array');
}
// Get guide stats (with fallback)
let guideStats = { total_guides: 0, city_guides: 0, mountain_guides: 0, fishing_guides: 0 };
try {
const statsResult = await db.query(`
SELECT COUNT(*) as total_guides,
COUNT(CASE WHEN specialization = 'city' THEN 1 END) as city_guides,
COUNT(CASE WHEN specialization = 'mountain' THEN 1 END) as mountain_guides,
COUNT(CASE WHEN specialization = 'fishing' THEN 1 END) as fishing_guides
FROM guides
WHERE is_active = true
`);
guideStats = statsResult.rows[0] || guideStats;
} catch (err) {
console.log('No guide stats found, using defaults');
}
res.render('index', {
title: 'Корея Тур Агентство - Откройте Корею',
featuredRoutes: featuredRoutes,
recentArticles: recentArticles,
guideStats: guideStats,
page: 'home'
});
} catch (error) {
console.error('Error loading home page:', error);
res.render('index', {
title: 'Корея Тур Агентство - Откройте Корею',
featuredRoutes: [],
recentArticles: [],
guideStats: { total_guides: 0, city_guides: 0, mountain_guides: 0, fishing_guides: 0 },
page: 'home'
});
}
});
// About page
router.get('/about', (req, res) => {
res.render('about', {
title: 'О нас - Корея Тур Агентство',
page: 'about'
});
});
// Contact page
router.get('/contact', (req, res) => {
res.render('contact', {
title: 'Свяжитесь с нами - Корея Тур Агентство',
page: 'contact'
});
});
// Contact form submission
router.post('/contact', async (req, res) => {
try {
const { name, email, phone, subject, message } = req.body;
// Save contact form to database
await db.query(`
INSERT INTO contact_messages (name, email, phone, subject, message)
VALUES ($1, $2, $3, $4, $5)
`, [name, email, phone, subject, message]);
res.json({ success: true, message: 'Thank you for your message. We will get back to you soon!' });
} catch (error) {
console.error('Error submitting contact form:', error);
res.status(500).json({ success: false, message: 'Error submitting message. Please try again.' });
}
});
export default router;

91
src/routes/routes.js Normal file
View File

@@ -0,0 +1,91 @@
const express = require('express');
const db = require('../config/database');
const router = express.Router();
// Routes listing page
router.get('/', async (req, res) => {
try {
const { type } = req.query;
let query = `
SELECT r.*, g.name as guide_name
FROM routes r
LEFT JOIN guides g ON r.guide_id = g.id
WHERE r.is_active = true
`;
const params = [];
if (type && ['city', 'mountain', 'fishing'].includes(type)) {
query += ` AND r.type = $1`;
params.push(type);
}
query += ` ORDER BY r.is_featured DESC, r.created_at DESC`;
const result = await db.query(query, params);
const routes = result.rows;
res.render('routes/index', {
title: 'Туры по Корее - Корея Тур Агентство',
routes: routes,
currentType: type || 'all',
page: 'routes'
});
} catch (error) {
console.error('Error loading routes:', error);
res.render('routes/index', {
title: 'Туры по Корее - Корея Тур Агентство',
routes: [],
currentType: 'all',
page: 'routes'
});
}
});
// Single route page
router.get('/:id', async (req, res) => {
try {
const routeId = parseInt(req.params.id);
// Get route details
const routeResult = await db.query(`
SELECT r.*, g.name as guide_name, g.bio as guide_bio, g.image_url as guide_image
FROM routes r
LEFT JOIN guides g ON r.guide_id = g.id
WHERE r.id = $1 AND r.is_active = true
`, [routeId]);
if (routeResult.rows.length === 0) {
return res.status(404).render('error', {
title: 'Тур не найден',
message: 'Запрашиваемый тур не существует или недоступен.',
page: 'error'
});
}
const route = routeResult.rows[0];
// Get reviews for this route
const reviewsResult = await db.query(`
SELECT * FROM reviews
WHERE route_id = $1 AND is_approved = true
ORDER BY created_at DESC
LIMIT 10
`, [routeId]);
res.render('routes/detail', {
title: `${route.title} - Корея Тур Агентство`,
route: route,
reviews: reviewsResult.rows,
page: 'routes'
});
} catch (error) {
console.error('Error loading route details:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Произошла ошибка при загрузке информации о туре.',
page: 'error'
});
}
});
module.exports = router;

123
src/routes/tours.js Normal file
View File

@@ -0,0 +1,123 @@
import express from 'express';
import db from '../config/database.js';
const router = express.Router();
// List all routes
router.get('/', async (req, res) => {
try {
const { type, sort, search } = req.query;
let query = `
SELECT r.*, g.name as guide_name
FROM routes r
LEFT JOIN guides g ON r.guide_id = g.id
WHERE r.is_active = true
`;
const params = [];
let paramCount = 0;
// Filter by type
if (type && ['city', 'mountain', 'fishing'].includes(type)) {
paramCount++;
query += ` AND r.type = $${paramCount}`;
params.push(type);
}
// Search functionality
if (search) {
paramCount++;
query += ` AND (r.title ILIKE $${paramCount} OR r.description ILIKE $${paramCount})`;
params.push(`%${search}%`);
}
// Sorting
switch (sort) {
case 'price_low':
query += ' ORDER BY r.price ASC';
break;
case 'price_high':
query += ' ORDER BY r.price DESC';
break;
case 'duration':
query += ' ORDER BY r.duration ASC';
break;
default:
query += ' ORDER BY r.created_at DESC';
}
const routes = await db.query(query, params);
res.render('routes/index', {
title: 'Туры по Корее - Корея Тур Агентство',
routes: routes.rows,
currentType: type || 'all',
currentSort: sort || 'newest',
searchQuery: search || '',
page: 'routes'
});
} catch (error) {
console.error('Error loading routes:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Не удалось загрузить список туров',
error: error
});
}
});
// Route details
router.get('/:id', async (req, res) => {
try {
const routeId = req.params.id;
const route = await db.query(`
SELECT r.*, g.name as guide_name, g.bio as guide_bio,
g.phone as guide_phone, g.email as guide_email,
g.languages as guide_languages, g.experience as guide_experience
FROM routes r
LEFT JOIN guides g ON r.guide_id = g.id
WHERE r.id = $1 AND r.is_active = true
`, [routeId]);
if (route.rows.length === 0) {
return res.status(404).render('error', {
title: '404 - Тур не найден',
message: 'Запрашиваемый тур не существует.',
error: { status: 404 }
});
}
// Get related routes
const relatedRoutes = await db.query(`
SELECT id, title, description, type, price, duration, image_url
FROM routes
WHERE type = $1 AND id != $2 AND is_active = true
ORDER BY RANDOM()
LIMIT 3
`, [route.rows[0].type, routeId]);
// Get reviews for this route
const reviewsResult = await db.query(`
SELECT * FROM reviews
WHERE route_id = $1 AND is_approved = true
ORDER BY created_at DESC
LIMIT 10
`, [routeId]);
res.render('routes/detail', {
title: `${route.rows[0].title} - Корея Тур Агентство`,
route: route.rows[0],
relatedRoutes: relatedRoutes.rows,
reviews: reviewsResult.rows,
page: 'routes'
});
} catch (error) {
console.error('Error loading route details:', error);
res.status(500).render('error', {
title: 'Ошибка',
message: 'Не удалось загрузить детали тура',
error: error
});
}
});
export default router;

130
start-dev.sh Executable file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
# Korea Tourism Agency - Development Setup Script
# Скрипт для быстрого запуска среды разработки
echo "🇰🇷 Korea Tourism Agency - Development Setup"
echo "============================================="
echo ""
# Проверка Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker не установлен. Установите Docker и попробуйте снова."
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose не установлен. Установите Docker Compose и попробуйте снова."
exit 1
fi
if ! docker info > /dev/null 2>&1; then
echo "❌ Docker не запущен. Запустите Docker сначала."
exit 1
fi
echo "✅ Docker найден и запущен"
# Создание .env файла если его нет
if [ ! -f .env ]; then
echo "📝 Создание файла .env..."
cat > .env << 'EOL'
# Database Configuration
DB_HOST=postgres
DB_PORT=5432
DB_NAME=korea_tourism
DB_USER=tourism_user
DB_PASSWORD=tourism_password
# Application Configuration
PORT=3000
NODE_ENV=development
SESSION_SECRET=korea-tourism-secret-key-2024
# File Upload Configuration
UPLOAD_PATH=/app/public/uploads
MAX_FILE_SIZE=5242880
# Site Information
SITE_NAME=Korea Tourism Agency
CONTACT_EMAIL=info@koreatourism.com
CONTACT_PHONE=+82-2-1234-5678
# Admin Configuration
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
EOL
echo "✅ Файл .env создан"
else
echo "✅ Файл .env уже существует"
fi
# Создание необходимых директорий
echo "📁 Создание директорий..."
mkdir -p public/uploads/routes
mkdir -p public/uploads/guides
mkdir -p public/uploads/articles
mkdir -p database/backups
# Остановка существующих контейнеров
echo ""
echo "🛑 Остановка существующих контейнеров..."
docker-compose down
# Сборка и запуск контейнеров
echo ""
echo "🏗️ Сборка и запуск контейнеров..."
docker-compose build
docker-compose up -d
# Ожидание запуска базы данных
echo ""
echo "⏳ Ожидание запуска базы данных..."
sleep 15
# Проверка статуса контейнеров
echo ""
echo "📊 Статус контейнеров:"
docker-compose ps
# Выполнение миграций
echo ""
echo "🔄 Выполнение миграций базы данных..."
docker-compose exec app node database/migrate.js
# Заполнение тестовыми данными
echo ""
echo "📦 Заполнение тестовыми данными..."
docker-compose exec app node database/seed.js
# Проверка логов
echo ""
echo "📝 Последние логи приложения:"
docker-compose logs --tail=5 app
echo ""
echo "🎉 Установка завершена!"
echo "=================================="
echo ""
echo "🌐 Сайт доступен по адресу:"
echo " 🏠 Главная страница: http://localhost:3000"
echo " ⚙️ Админ панель: http://localhost:3000/admin"
echo " 🗄️ Adminer (БД): http://localhost:8080"
echo ""
echo "🔐 Данные для входа в админку:"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo "🗄️ Данные для подключения к БД (Adminer):"
echo " System: PostgreSQL"
echo " Server: postgres"
echo " Username: tourism_user"
echo " Password: tourism_password"
echo " Database: korea_tourism"
echo ""
echo "📝 Полезные команды:"
echo " docker-compose logs -f app # Просмотр логов"
echo " docker-compose restart app # Перезапуск приложения"
echo " docker-compose down # Остановка контейнеров"
echo ""
echo "🎯 Готово к разработке! Откройте http://localhost:3000"

111
views/about.ejs Normal file
View File

@@ -0,0 +1,111 @@
<!-- 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>
</div>
</section>
<!-- About Content -->
<section class="py-5">
<div class="container">
<div class="row align-items-center mb-5">
<div class="col-lg-6">
<h2 class="mb-4">Наша миссия</h2>
<p class="lead">Корея Тур Агентство основано с целью показать туристам настоящую Корею - её культуру, природу, традиции и современность.</p>
<p>Мы предлагаем широкий спектр туров: от городских экскурсий по историческим дворцам Сеула до горных походов в национальные парки и захватывающей морской рыбалки у берегов Пусана.</p>
<p>Наша команда состоит из профессиональных гидов, которые говорят на русском языке и имеют глубокие знания о корейской культуре и истории.</p>
</div>
<div class="col-lg-6">
<img src="/images/about-us.jpg" class="img-fluid rounded" alt="О нас">
</div>
</div>
<div class="row">
<div class="col-md-4 text-center mb-4">
<div class="card h-100">
<div class="card-body">
<i class="fas fa-users text-primary" style="font-size: 3rem;"></i>
<h5 class="card-title mt-3">Опытная команда</h5>
<p class="card-text">Более 50 проведенных туров и сотни довольных клиентов</p>
</div>
</div>
</div>
<div class="col-md-4 text-center mb-4">
<div class="card h-100">
<div class="card-body">
<i class="fas fa-language text-primary" style="font-size: 3rem;"></i>
<h5 class="card-title mt-3">Русскоязычные гиды</h5>
<p class="card-text">Все наши гиды свободно говорят по-русски и корейски</p>
</div>
</div>
</div>
<div class="col-md-4 text-center mb-4">
<div class="card h-100">
<div class="card-body">
<i class="fas fa-shield-alt text-primary" style="font-size: 3rem;"></i>
<h5 class="card-title mt-3">Безопасность</h5>
<p class="card-text">Полная страховка и соблюдение всех мер безопасности</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Team Section -->
<section class="bg-light py-5">
<div class="container">
<h2 class="text-center mb-5">Почему выбирают нас?</h2>
<div class="row">
<div class="col-lg-3 col-md-6 text-center mb-4">
<div class="mb-3">
<i class="fas fa-star text-warning" style="font-size: 2rem;"></i>
</div>
<h5>Высокие рейтинги</h5>
<p class="text-muted">4.8/5 средний рейтинг от наших клиентов</p>
</div>
<div class="col-lg-3 col-md-6 text-center mb-4">
<div class="mb-3">
<i class="fas fa-route text-primary" style="font-size: 2rem;"></i>
</div>
<h5>Уникальные маршруты</h5>
<p class="text-muted">Эксклюзивные места, недоступные массовому туризму</p>
</div>
<div class="col-lg-3 col-md-6 text-center mb-4">
<div class="mb-3">
<i class="fas fa-headset text-success" style="font-size: 2rem;"></i>
</div>
<h5>Поддержка 24/7</h5>
<p class="text-muted">Круглосуточная поддержка во время путешествия</p>
</div>
<div class="col-lg-3 col-md-6 text-center mb-4">
<div class="mb-3">
<i class="fas fa-handshake text-info" style="font-size: 2rem;"></i>
</div>
<h5>Индивидуальный подход</h5>
<p class="text-muted">Каждый тур адаптируется под ваши потребности</p>
</div>
</div>
</div>
</section>
<!-- Call to Action -->
<section class="py-5">
<div class="container text-center">
<h2 class="mb-4">Готовы исследовать Корею?</h2>
<p class="lead text-muted mb-4">Свяжитесь с нами и начните планировать ваше незабываемое путешествие уже сегодня!</p>
<div class="d-flex flex-wrap justify-content-center gap-3">
<a href="/routes" class="btn btn-primary btn-lg">
<i class="fas fa-route me-1"></i>Посмотреть туры
</a>
<a href="/contact" class="btn btn-outline-primary btn-lg">
<i class="fas fa-envelope me-1"></i>Связаться с нами
</a>
</div>
</div>
</section>

195
views/articles/detail.ejs Normal file
View File

@@ -0,0 +1,195 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white py-5">
<div class="container">
<div class="row">
<div class="col-lg-8">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/" class="text-light">Главная</a></li>
<li class="breadcrumb-item"><a href="/articles" class="text-light">Статьи</a></li>
<li class="breadcrumb-item active" aria-current="page"><%= article.title %></li>
</ol>
</nav>
<% if (article.category) { %>
<span class="badge bg-light text-dark mb-3">
<%= getCategoryLabel(article.category) %>
</span>
<% } %>
<h1 class="display-5 fw-bold mb-3"><%= article.title %></h1>
<% if (article.excerpt) { %>
<p class="lead mb-4"><%= article.excerpt %></p>
<% } %>
<div class="d-flex align-items-center text-light-50">
<i class="fas fa-calendar-alt me-2"></i>
<span class="me-4"><%= formatDate(article.created_at) %></span>
<i class="fas fa-eye me-2"></i>
<span><%= article.views %> просмотров</span>
</div>
</div>
</div>
</div>
</section>
<!-- Article Content -->
<section class="py-5">
<div class="container">
<div class="row">
<div class="col-lg-8">
<article class="mb-5">
<div class="mb-4">
<img src="<%= (article.image_url && article.image_url.trim()) ? article.image_url : '/images/placeholder.jpg' %>" alt="<%= article.title %>" class="img-fluid rounded shadow">
</div>
<div class="article-content">
<%- article.content %>
</div>
</article>
<!-- Social Share -->
<div class="border-top pt-4 mb-5">
<h5 class="mb-3">Поделиться статьей:</h5>
<div class="d-flex gap-2">
<a href="#" class="btn btn-outline-primary btn-sm" onclick="shareToFacebook()">
<i class="fab fa-facebook-f me-1"></i>Facebook
</a>
<a href="#" class="btn btn-outline-info btn-sm" onclick="shareToTwitter()">
<i class="fab fa-twitter me-1"></i>Twitter
</a>
<a href="#" class="btn btn-outline-success btn-sm" onclick="copyLink()">
<i class="fas fa-link me-1"></i>Копировать ссылку
</a>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-newspaper me-2"></i>Похожие статьи
</h5>
</div>
<div class="card-body">
<% if (relatedArticles && relatedArticles.length > 0) { %>
<% relatedArticles.forEach(related => { %>
<div class="mb-3 pb-3 border-bottom">
<div class="mb-2">
<img src="<%= (related.image_url && related.image_url.trim()) ? related.image_url : '/images/placeholder.jpg' %>" alt="<%= related.title %>"
class="img-fluid rounded" style="height: 80px; width: 100%; object-fit: cover;">
</div>
<h6 class="mb-1">
<a href="/articles/<%= related.id %>" class="text-decoration-none">
<%= related.title %>
</a>
</h6>
<% if (related.excerpt) { %>
<p class="text-muted small mb-1"><%= truncateText(related.excerpt, 80) %></p>
<% } %>
<small class="text-muted">
<i class="fas fa-calendar-alt me-1"></i>
<%= formatDateShort(related.created_at) %>
</small>
</div>
<% }); %>
<% } else { %>
<p class="text-muted mb-0">Нет похожих статей</p>
<% } %>
</div>
</div>
<!-- Back to Articles -->
<div class="mt-4">
<a href="/articles" class="btn btn-outline-primary w-100">
<i class="fas fa-arrow-left me-1"></i>Вернуться к статьям
</a>
</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="/routes" class="btn btn-primary btn-lg me-3">
<i class="fas fa-route me-1"></i>Посмотреть туры
</a>
<a href="/contact" class="btn btn-outline-primary btn-lg">
<i class="fas fa-envelope me-1"></i>Свяжитесь с нами
</a>
</div>
</section>
<script>
function shareToFacebook() {
const url = encodeURIComponent(window.location.href);
const title = encodeURIComponent('<%= article.title %>');
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
}
function shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const title = encodeURIComponent('<%= article.title %>');
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${title}`, '_blank', 'width=600,height=400');
}
function copyLink() {
navigator.clipboard.writeText(window.location.href).then(() => {
alert('Ссылка скопирована в буфер обмена!');
});
}
</script>
<style>
.article-content {
font-size: 1.1rem;
line-height: 1.7;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
}
.article-content p {
margin-bottom: 1.5rem;
}
.article-content img {
max-width: 100%;
height: auto;
margin: 1.5rem 0;
border-radius: 8px;
}
.article-content blockquote {
background-color: #f8f9fa;
border-left: 4px solid #007bff;
padding: 1rem 1.5rem;
margin: 2rem 0;
font-style: italic;
}
.article-content ul,
.article-content ol {
padding-left: 2rem;
margin-bottom: 1.5rem;
}
.article-content li {
margin-bottom: 0.5rem;
}
</style>

80
views/articles/index.ejs Normal file
View File

@@ -0,0 +1,80 @@
<!-- 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>
</div>
</section>
<!-- Articles Grid -->
<section class="py-5">
<div class="container">
<% if (articles.length === 0) { %>
<div class="text-center py-5">
<i class="fas fa-newspaper text-muted" style="font-size: 4rem;"></i>
<h3 class="mt-3 text-muted">Статьи не найдены</h3>
<p class="text-muted">В данный момент нет опубликованных статей.</p>
</div>
<% } else { %>
<div class="row">
<% articles.forEach(article => { %>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 shadow-sm">
<% if (article.image_url && article.image_url.trim()) { %>
<img src="<%= article.image_url %>" class="card-img-top" alt="<%= article.title %>" style="height: 200px; object-fit: cover;">
<% } else { %>
<img src="/images/placeholder.jpg" class="card-img-top" alt="<%= article.title %>" style="height: 200px; object-fit: cover;">
<% } %>
<div class="card-body d-flex flex-column">
<div class="mb-2">
<span class="badge <%= article.category === 'travel-tips' ? 'bg-success' : article.category === 'food' ? 'bg-warning' : article.category === 'culture' ? 'bg-info' : article.category === 'nature' ? 'bg-success' : article.category === 'history' ? 'bg-secondary' : 'bg-primary' %>">
<%= getCategoryLabel(article.category) %>
</span>
</div>
<h5 class="card-title"><%= article.title %></h5>
<p class="card-text text-muted flex-grow-1"><%= article.excerpt || truncateText(article.content, 120) %></p>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center mb-3">
<small class="text-muted">
<i class="fas fa-eye me-1"></i><%= article.views || 0 %> просмотров
</small>
<small class="text-muted">
<i class="fas fa-calendar me-1"></i><%= formatDate(article.created_at) %>
</small>
</div>
<a href="/articles/<%= article.id %>" class="btn btn-primary w-100">
<i class="fas fa-book-open me-1"></i>Читать
</a>
</div>
</div>
</div>
</div>
<% }); %>
</div>
<% } %>
</div>
</section>
<!-- Newsletter Signup -->
<section class="bg-light py-5">
<div class="container text-center">
<h2 class="mb-4">Не пропустите новые статьи!</h2>
<p class="lead text-muted mb-4">Подпишитесь на нашу рассылку и получайте самые интересные материалы о Корее</p>
<div class="row justify-content-center">
<div class="col-md-6">
<form class="d-flex">
<input type="email" class="form-control me-2" placeholder="Ваш email адрес">
<button class="btn btn-primary" type="submit">Подписаться</button>
</form>
</div>
</div>
</div>
</section>

191
views/contact.ejs Normal file
View File

@@ -0,0 +1,191 @@
<!-- 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>
</div>
</section>
<!-- Contact Form and Info -->
<section class="py-5">
<div class="container">
<div class="row">
<div class="col-lg-8 mb-4">
<div class="card">
<div class="card-body">
<h3 class="card-title mb-4">Напишите нам</h3>
<form id="contactForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="name" class="form-label">Ваше имя *</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="col-md-6 mb-3">
<label for="email" class="form-label">Email *</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" class="form-control" id="phone" name="phone">
</div>
<div class="mb-3">
<label for="subject" class="form-label">Тема сообщения</label>
<select class="form-select" id="subject" name="subject">
<option value="">Выберите тему...</option>
<option value="booking">Бронирование тура</option>
<option value="info">Информация о турах</option>
<option value="custom">Индивидуальный тур</option>
<option value="feedback">Отзыв</option>
<option value="other">Другое</option>
</select>
</div>
<div class="mb-3">
<label for="message" class="form-label">Сообщение *</label>
<textarea class="form-control" id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane me-1"></i>Отправить сообщение
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Contact Info -->
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Контактная информация</h5>
<div class="mb-3">
<i class="fas fa-map-marker-alt text-primary me-2"></i>
<strong>Адрес:</strong><br>
<small class="text-muted">Москва, ул. Примерная, д. 123</small>
</div>
<div class="mb-3">
<i class="fas fa-phone text-primary me-2"></i>
<strong>Телефон:</strong><br>
<small class="text-muted">+7 (495) 123-45-67</small>
</div>
<div class="mb-3">
<i class="fas fa-envelope text-primary me-2"></i>
<strong>Email:</strong><br>
<small class="text-muted">info@koreatour.ru</small>
</div>
<div class="mb-3">
<i class="fas fa-clock text-primary me-2"></i>
<strong>Время работы:</strong><br>
<small class="text-muted">Пн-Пт: 9:00-18:00<br>Сб: 10:00-16:00<br>Вс: выходной</small>
</div>
</div>
</div>
<!-- Social Media -->
<div class="card">
<div class="card-body">
<h5 class="card-title">Мы в соцсетях</h5>
<div class="d-flex gap-2">
<a href="#" class="btn btn-outline-primary btn-sm">
<i class="fab fa-facebook-f"></i>
</a>
<a href="#" class="btn btn-outline-primary btn-sm">
<i class="fab fa-instagram"></i>
</a>
<a href="#" class="btn btn-outline-primary btn-sm">
<i class="fab fa-youtube"></i>
</a>
<a href="#" class="btn btn-outline-primary btn-sm">
<i class="fab fa-telegram"></i>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- FAQ Section -->
<section class="bg-light py-5">
<div class="container">
<h2 class="text-center mb-5">Часто задаваемые вопросы</h2>
<div class="row">
<div class="col-lg-8 mx-auto">
<div class="accordion" id="faqAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="faq1">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapse1">
Нужна ли виза для поездки в Корею?
</button>
</h2>
<div id="collapse1" class="accordion-collapse collapse show" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Граждане России могут находиться в Южной Корее без визы до 60 дней для туристических целей. Необходим загранпаспорт, действующий минимум 6 месяцев.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="faq2">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse2">
Какая валюта используется в Корее?
</button>
</h2>
<div id="collapse2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
В Корее используется корейская вона (KRW или ₩). Обменять деньги можно в банках, обменных пунктах или снять в банкоматах.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="faq3">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse3">
Включена ли страховка в стоимость тура?
</button>
</h2>
<div id="collapse3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Да, все наши туры включают базовую туристическую страховку. По желанию можно оформить расширенную страховку.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<script>
document.getElementById('contactForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
try {
const response = await fetch('/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert('Спасибо! Ваше сообщение отправлено. Мы свяжемся с вами в ближайшее время.');
e.target.reset();
} else {
alert('Произошла ошибка при отправке сообщения. Попробуйте еще раз.');
}
} catch (error) {
alert('Произошла ошибка при отправке сообщения. Попробуйте еще раз.');
}
});
</script>

34
views/error.ejs Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ошибка - Корея Тур Агентство</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body class="bg-light">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-0 shadow">
<div class="card-body text-center p-5">
<i class="fas fa-exclamation-triangle text-warning display-1 mb-4"></i>
<h2 class="mb-3"><%= title || 'Произошла ошибка' %></h2>
<p class="text-muted mb-4"><%= message || 'При загрузке страницы произошла ошибка.' %></p>
<% if (error && error.status === 404) { %>
<p class="small text-muted mb-4">Страница, которую вы ищете, не существует или была перемещена.</p>
<% } %>
<a href="/" class="btn btn-primary">
<i class="fas fa-home me-1"></i>Вернуться на главную
</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary ms-2">
<i class="fas fa-arrow-left me-1"></i>Назад
</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

108
views/guides/index.ejs Normal file
View File

@@ -0,0 +1,108 @@
<!-- 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>
</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">
<% 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/placeholder.jpg" class="card-img-top" alt="<%= guide.name %>" style="height: 250px; object-fit: cover;">
<% } %>
<div class="card-body d-flex flex-column">
<h5 class="card-title"><%= guide.name %></h5>
<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>
<% } %>
</div>
<% if (guide.bio) { %>
<p class="card-text text-muted flex-grow-1"><%= truncateText(guide.bio, 120) %></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">
<%- 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>
</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>

260
views/guides/profile.ejs Normal file
View File

@@ -0,0 +1,260 @@
<!-- Hero Section -->
<section class="hero-section 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">
<% if (guide.image_url && guide.image_url.trim()) { %>
<img src="<%= guide.image_url %>" alt="<%= guide.name %>"
class="img-fluid rounded-circle shadow-lg mb-4 mb-lg-0"
style="width: 200px; height: 200px; object-fit: cover;">
<% } else { %>
<img src="/images/placeholder.jpg" alt="<%= guide.name %>"
class="img-fluid rounded-circle shadow-lg mb-4 mb-lg-0"
style="width: 200px; height: 200px; object-fit: cover;">
<% } %>
</div>
<div class="col-lg-8">
<h1 class="display-4 fw-bold mb-3"><%= guide.name %></h1>
<div class="mb-4">
<% if (guide.specialization === 'city') { %>
<span class="badge bg-info fs-6 me-2">
<i class="fas fa-city me-1"></i>Городские туры
</span>
<% } else if (guide.specialization === 'mountain') { %>
<span class="badge bg-success fs-6 me-2">
<i class="fas fa-mountain me-1"></i>Горные походы
</span>
<% } else if (guide.specialization === 'fishing') { %>
<span class="badge bg-info fs-6 me-2">
<i class="fas fa-fish me-1"></i>Рыбалка
</span>
<% } %>
<span class="badge bg-warning text-dark fs-6">
<i class="fas fa-star me-1"></i>
<%= guide.experience %> лет опыта
</span>
</div>
<% if (guide.languages) { %>
<p class="mb-3">
<strong>Языки:</strong> <%= guide.languages %>
</p>
<% } %>
<% if (guide.avg_rating) { %>
<div class="mb-3">
<span class="me-2">Рейтинг:</span>
<%- generateStars(guide.avg_rating) %>
<span class="ms-2">(<%= parseFloat(guide.avg_rating).toFixed(1) %>/5)</span>
<% if (guide.review_count) { %>
<small class="text-light-50 ms-2"><%= guide.review_count %> отзывов</small>
<% } %>
</div>
<% } %>
<div class="row">
<div class="col-md-6">
<p class="mb-1">
<i class="fas fa-route me-2"></i>
<strong><%= guide.route_count || 0 %></strong> туров
</p>
</div>
<% if (guide.phone) { %>
<div class="col-md-6">
<p class="mb-1">
<i class="fas fa-phone me-2"></i>
<a href="tel:<%= guide.phone %>" class="text-light"><%= guide.phone %></a>
</p>
</div>
<% } %>
</div>
</div>
</div>
</div>
</section>
<!-- Guide Details -->
<section class="py-5">
<div class="container">
<div class="row">
<div class="col-lg-8">
<!-- Bio -->
<% if (guide.bio) { %>
<div class="card mb-4">
<div class="card-header">
<h4 class="card-title mb-0">
<i class="fas fa-user me-2"></i>О гиде
</h4>
</div>
<div class="card-body">
<p class="mb-0"><%= guide.bio %></p>
</div>
</div>
<% } %>
<!-- Tours -->
<div class="card mb-4">
<div class="card-header">
<h4 class="card-title mb-0">
<i class="fas fa-route me-2"></i>Туры с этим гидом
</h4>
</div>
<div class="card-body">
<% if (routes && routes.length > 0) { %>
<div class="row">
<% routes.forEach(route => { %>
<div class="col-md-6 mb-3">
<div class="card h-100">
<%
let placeholderImage = '/images/placeholder.jpg';
if (route.type === 'city') {
placeholderImage = '/images/city-tour-placeholder.webp';
} else if (route.type === 'mountain') {
placeholderImage = '/images/mountain-placeholder.jpg';
} else if (route.type === 'fishing') {
placeholderImage = '/images/fish-placeholder.jpg';
}
%>
<% if (route.image_url && route.image_url.trim()) { %>
<img src="<%= route.image_url %>" class="card-img-top"
alt="<%= route.title %>" style="height: 150px; object-fit: cover;">
<% } else { %>
<img src="<%= placeholderImage %>" class="card-img-top"
alt="<%= route.title %>" style="height: 150px; object-fit: cover;">
<% } %>
<div class="card-body">
<h6 class="card-title"><%= route.title %></h6>
<p class="card-text text-muted small"><%= truncateText(route.description, 80) %></p>
<div class="d-flex justify-content-between align-items-center">
<strong class="text-primary">₩<%= formatCurrency(route.price) %></strong>
<small class="text-muted"><%= route.duration %> дн.</small>
</div>
<a href="/routes/<%= route.id %>" class="btn btn-outline-primary btn-sm mt-2 w-100">
Подробнее
</a>
</div>
</div>
</div>
<% }); %>
</div>
<% } else { %>
<p class="text-muted mb-0">В данный момент у этого гида нет активных туров.</p>
<% } %>
</div>
</div>
<!-- Reviews -->
<div class="card">
<div class="card-header">
<h4 class="card-title mb-0">
<i class="fas fa-star me-2"></i>Отзывы
</h4>
</div>
<div class="card-body">
<% if (reviews && reviews.length > 0) { %>
<% reviews.forEach(review => { %>
<div class="border-bottom pb-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="mb-1"><%= review.author_name %></h6>
<% if (review.route_title) { %>
<small class="text-muted">Тур: <%= review.route_title %></small>
<% } %>
</div>
<div class="text-end">
<% if (review.rating) { %>
<div class="mb-1">
<%- generateStars(review.rating) %>
</div>
<% } %>
<small class="text-muted"><%= formatDateShort(review.created_at) %></small>
</div>
</div>
<p class="mb-0"><%= review.comment %></p>
</div>
<% }); %>
<% } else { %>
<p class="text-muted mb-0">Пока нет отзывов об этом гиде.</p>
<% } %>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Contact Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-address-card me-2"></i>Контактная информация
</h5>
</div>
<div class="card-body">
<% if (guide.phone) { %>
<p class="mb-2">
<i class="fas fa-phone me-2"></i>
<a href="tel:<%= guide.phone %>"><%= guide.phone %></a>
</p>
<% } %>
<% if (guide.email) { %>
<p class="mb-2">
<i class="fas fa-envelope me-2"></i>
<a href="mailto:<%= guide.email %>"><%= guide.email %></a>
</p>
<% } %>
<% if (guide.languages) { %>
<p class="mb-2">
<i class="fas fa-language me-2"></i>
<%= guide.languages %>
</p>
<% } %>
<a href="/contact" class="btn btn-primary w-100 mt-3">
<i class="fas fa-envelope me-1"></i>Связаться с гидом
</a>
</div>
</div>
<!-- Stats Card -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Статистика
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="border-end">
<h4 class="text-primary mb-1"><%= guide.route_count || 0 %></h4>
<small class="text-muted">Туров</small>
</div>
</div>
<div class="col-6 mb-3">
<h4 class="text-success mb-1"><%= guide.experience %></h4>
<small class="text-muted">Лет опыта</small>
</div>
</div>
<% if (guide.avg_rating) { %>
<div class="text-center">
<h4 class="text-warning mb-1"><%= parseFloat(guide.avg_rating).toFixed(1) %></h4>
<small class="text-muted">Средний рейтинг</small>
</div>
<% } %>
</div>
</div>
<!-- Back Button -->
<div class="mt-4">
<a href="/guides" class="btn btn-outline-primary w-100">
<i class="fas fa-arrow-left me-1"></i>Все гиды
</a>
</div>
</div>
</div>
</div>
</section>

283
views/index.ejs Normal file
View File

@@ -0,0 +1,283 @@
<!-- Hero Section -->
<section class="hero-section position-relative overflow-hidden">
<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="col-lg-6" data-aos="fade-right">
<h1 class="hero-title display-3 fw-bold text-white mb-4">
Откройте красоту
<span class="text-gradient">Кореи</span>
</h1>
<p class="hero-subtitle fs-5 text-white-50 mb-5">
Погрузитесь в аутентичную корейскую культуру, насладитесь захватывающими пейзажами и
незабываемыми приключениями с нашими экспертными местными гидами.
</p>
<div class="hero-buttons d-flex flex-wrap gap-3">
<a href="/routes" class="btn btn-primary btn-lg px-4 py-3 rounded-pill">
<i class="fas fa-route me-2"></i>Исследовать туры
</a>
<a href="/guides" class="btn btn-outline-light btn-lg px-4 py-3 rounded-pill">
<i class="fas fa-user-tie me-2"></i>Наши гиды
</a>
</div>
</div>
<div class="col-lg-6" data-aos="fade-left" data-aos-delay="200">
<div class="hero-image-container">
<img src="/images/korea-hero.jpg" alt="Красивая Корея" class="img-fluid rounded-4 shadow-lg">
<div class="floating-card position-absolute">
<div class="card border-0 shadow-lg">
<div class="card-body p-4">
<div class="d-flex align-items-center">
<div class="icon-circle bg-primary text-white me-3">
<i class="fas fa-users"></i>
</div>
<div>
<h6 class="fw-bold mb-0">1000+ довольных туристов</h6>
<small class="text-muted">Присоединяйтесь к нам</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Quick Search Section -->
<section class="search-section py-5 bg-light">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card border-0 shadow-lg" data-aos="fade-up">
<div class="card-body p-4">
<h4 class="text-center mb-4 fw-bold">Найдите своё идеальное корейское приключение</h4>
<form class="search-form">
<div class="row g-3">
<div class="col-md-4">
<select class="form-select form-select-lg" name="type">
<option value="">Тип тура</option>
<option value="city">Городские туры</option>
<option value="mountain">Горные походы</option>
<option value="fishing">Рыбалка</option>
</select>
</div>
<div class="col-md-4">
<input type="date" class="form-control form-control-lg" name="date">
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="fas fa-search me-2"></i>Найти туры
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Featured Tours -->
<section class="featured-tours py-5">
<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">Отобранные впечатления, которые показывают лучшее в Корее</p>
</div>
<div class="row g-4">
<% if (featuredRoutes && featuredRoutes.length > 0) { %>
<% featuredRoutes.forEach(function(route, index) { %>
<div class="col-lg-4 col-md-6" data-aos="fade-up" data-aos-delay="<%= index * 100 %>">
<div class="tour-card card border-0 shadow-lg h-100 overflow-hidden">
<div class="position-relative">
<%
let placeholderImage = '/images/placeholder.jpg';
if (route.type === 'city') {
placeholderImage = '/images/city-tour-placeholder.webp';
} else if (route.type === 'mountain') {
placeholderImage = '/images/mountain-placeholder.jpg';
} else if (route.type === 'fishing') {
placeholderImage = '/images/fish-placeholder.jpg';
}
%>
<img src="<%= (route.image_url && route.image_url.trim()) ? route.image_url : placeholderImage %>"
alt="<%= route.title %>"
class="card-img-top tour-image">
<div class="tour-overlay position-absolute top-0 start-0 w-100 h-100 d-flex align-items-end">
<div class="tour-price p-3">
<span class="badge bg-primary fs-6 px-3 py-2">
<%= formatCurrency(route.price) %>
</span>
</div>
</div>
<div class="tour-type position-absolute top-0 end-0 m-3">
<% if (route.type === 'city') { %>
<span class="badge bg-info"><i class="fas fa-city me-1"></i>Город</span>
<% } else if (route.type === 'mountain') { %>
<span class="badge bg-success"><i class="fas fa-mountain me-1"></i>Горы</span>
<% } else if (route.type === 'fishing') { %>
<span class="badge bg-warning"><i class="fas fa-fish me-1"></i>Рыбалка</span>
<% } %>
</div>
</div>
<div class="card-body p-4">
<h5 class="card-title fw-bold mb-3">
<a href="/routes/<%= route.id %>" class="text-decoration-none text-dark">
<%= route.title %>
</a>
</h5>
<p class="card-text text-muted mb-3">
<%= truncateText(route.description, 100) %>
</p>
<div class="tour-meta d-flex justify-content-between align-items-center mb-3">
<span class="text-muted">
<i class="fas fa-clock me-1"></i><%= route.duration %>h
</span>
<span class="text-muted">
<i class="fas fa-users me-1"></i>Max 10 people
</span>
</div>
<a href="/routes/<%= route.id %>" class="btn btn-outline-primary w-100">
View Details
</a>
</div>
</div>
</div>
<% }); %>
<% } else { %>
<div class="col-12 text-center py-5">
<p class="text-muted fs-5">No featured tours available at the moment.</p>
<a href="/routes" class="btn btn-primary">Посмотреть все туры</a>
</div>
<% } %>
</div>
<div class="text-center mt-5" data-aos="fade-up">
<a href="/routes" class="btn btn-primary btn-lg px-5 py-3 rounded-pill">
<i class="fas fa-eye me-2"></i>Посмотреть все туры
</a>
</div>
</div>
</section>
<!-- Guide Stats -->
<section class="stats-section py-5 bg-gradient-primary text-white">
<div class="container">
<div class="row text-center g-4">
<div class="col-lg-3 col-md-6" data-aos="fade-up" data-aos-delay="0">
<div class="stat-item">
<div class="stat-icon mb-3">
<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>
</div>
</div>
<div class="col-lg-3 col-md-6" data-aos="fade-up" data-aos-delay="100">
<div class="stat-item">
<div class="stat-icon mb-3">
<i class="fas fa-user-tie display-4"></i>
</div>
<h3 class="stat-number fw-bold display-5 mb-2"><%= guideStats.total_guides || 0 %></h3>
<p class="stat-label fs-5">Экспертные гиды</p>
</div>
</div>
<div class="col-lg-3 col-md-6" data-aos="fade-up" data-aos-delay="200">
<div class="stat-item">
<div class="stat-icon mb-3">
<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>
</div>
</div>
<div class="col-lg-3 col-md-6" data-aos="fade-up" data-aos-delay="300">
<div class="stat-item">
<div class="stat-icon mb-3">
<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>
</div>
</div>
</div>
</div>
</section>
<!-- Recent Articles -->
<section class="articles-section py-5">
<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>
</div>
<div class="row g-4">
<% if (recentArticles && recentArticles.length > 0) { %>
<% recentArticles.forEach(function(article, index) { %>
<div class="col-lg-4 col-md-6" data-aos="fade-up" data-aos-delay="<%= index * 100 %>">
<article class="article-card card border-0 shadow-lg h-100">
<img src="<%= (article.image_url && article.image_url.trim()) ? article.image_url : '/images/placeholder.jpg' %>"
alt="<%= article.title %>"
class="card-img-top article-image">
<div class="card-body p-4">
<h5 class="card-title fw-bold mb-3">
<a href="/articles/<%= article.id %>" class="text-decoration-none text-dark">
<%= article.title %>
</a>
</h5>
<p class="card-text text-muted mb-3">
<%= truncateText(article.excerpt, 120) %>
</p>
<div class="article-meta d-flex justify-content-between align-items-center">
<small class="text-muted">
<i class="fas fa-calendar me-1"></i>
<%= formatDate(article.created_at) %>
</small>
<a href="/articles/<%= article.id %>" class="btn btn-sm btn-outline-primary">
Подробнее
</a>
</div>
</div>
</article>
</div>
<% }); %>
<% } 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>
</div>
<% } %>
</div>
<div class="text-center mt-5" data-aos="fade-up">
<a href="/articles" class="btn btn-outline-primary btn-lg px-5 py-3 rounded-pill">
<i class="fas fa-newspaper me-2"></i>Читать больше статей
</a>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="cta-section py-5 bg-dark text-white">
<div class="container">
<div class="row align-items-center">
<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">
<a href="/contact" class="btn btn-primary btn-lg px-5 py-3 rounded-pill">
<i class="fas fa-envelope me-2"></i>Get in Touch
</a>
</div>
</div>
</div>
</section>

205
views/layout.ejs Normal file
View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title || siteName %></title>
<meta name="description" content="<%= siteDescription %>">
<!-- Favicon -->
<link rel="icon" href="/images/favicon.ico">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- AOS Animation -->
<link href="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.css" rel="stylesheet">
<!-- Swiper CSS -->
<link href="https://cdn.jsdelivr.net/npm/swiper@10/swiper-bundle.min.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="/css/main.css" rel="stylesheet">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="<%= title || siteName %>">
<meta property="og:description" content="<%= siteDescription %>">
<meta property="og:type" content="website">
<meta property="og:url" content="https://koreatour.ru">
<meta property="og:image" content="/images/korea-tourism-og.jpg">
<!-- Korean Language Tags -->
<meta name="keywords" content="Корея, туры, путешествие, гид, Korea travel, Korean tourism">
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/images/korea-logo.png" alt="Korea Tourism" height="40" class="me-2">
<span class="fw-bold"><%= siteName %></span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link <%= page === 'home' ? 'active' : '' %>" href="/">
<i class="fas fa-home me-1"></i>Главная
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle <%= page === 'routes' ? 'active' : '' %>" href="#" data-bs-toggle="dropdown">
<i class="fas fa-route me-1"></i>Туры
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/routes?type=city">
<i class="fas fa-city me-2"></i>Городские туры
</a></li>
<li><a class="dropdown-item" href="/routes?type=mountain">
<i class="fas fa-mountain me-2"></i>Горные походы
</a></li>
<li><a class="dropdown-item" href="/routes?type=fishing">
<i class="fas fa-fish me-2"></i>Морская рыбалка
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/routes">
<i class="fas fa-list me-2"></i>Все туры
</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link <%= page === 'guides' ? 'active' : '' %>" href="/guides">
<i class="fas fa-user-tie me-1"></i>Гиды
</a>
</li>
<li class="nav-item">
<a class="nav-link <%= page === 'articles' ? 'active' : '' %>" href="/articles">
<i class="fas fa-newspaper me-1"></i>Статьи
</a>
</li>
<li class="nav-item">
<a class="nav-link <%= page === 'about' ? 'active' : '' %>" href="/about">
<i class="fas fa-info-circle me-1"></i>О нас
</a>
</li>
<li class="nav-item">
<a class="nav-link <%= page === 'contact' ? 'active' : '' %>" href="/contact">
<i class="fas fa-envelope me-1"></i>Контакты
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
<%- body %>
</main>
<!-- Footer -->
<footer class="bg-dark text-white pt-5 pb-3">
<div class="container">
<div class="row">
<div class="col-lg-4 mb-4">
<h5 class="fw-bold mb-3">
<img src="/images/korea-logo.png" alt="Korea Tourism" height="30" class="me-2">
<%= siteName %>
</h5>
<p class="text-muted"><%= siteDescription %></p>
<div class="social-links">
<a href="#" class="text-white me-3"><i class="fab fa-facebook-f"></i></a>
<a href="#" class="text-white me-3"><i class="fab fa-instagram"></i></a>
<a href="#" class="text-white me-3"><i class="fab fa-youtube"></i></a>
<a href="#" class="text-white"><i class="fab fa-twitter"></i></a>
</div>
</div>
<div class="col-lg-2 mb-4">
<h6 class="fw-bold mb-3">Туры</h6>
<ul class="list-unstyled">
<li><a href="/routes?type=city" class="text-muted text-decoration-none">Городские туры</a></li>
<li><a href="/routes?type=mountain" class="text-muted text-decoration-none">Горные походы</a></li>
<li><a href="/routes?type=fishing" class="text-muted text-decoration-none">Морская рыбалка</a></li>
</ul>
</div>
<div class="col-lg-2 mb-4">
<h6 class="fw-bold mb-3">Информация</h6>
<ul class="list-unstyled">
<li><a href="/guides" class="text-muted text-decoration-none">Наши гиды</a></li>
<li><a href="/articles" class="text-muted text-decoration-none">Советы путешественникам</a></li>
<li><a href="/about" class="text-muted text-decoration-none">О компании</a></li>
</ul>
</div>
<div class="col-lg-4 mb-4">
<h6 class="fw-bold mb-3">Контакты</h6>
<p class="text-muted mb-2">
<i class="fas fa-map-marker-alt me-2"></i>
Москва, Россия
</p>
<p class="text-muted mb-2">
<i class="fas fa-phone me-2"></i>
+7 (495) 123-45-67
</p>
<p class="text-muted mb-2">
<i class="fas fa-envelope me-2"></i>
info@koreatour.ru
</p>
<div class="mt-3">
<h6 class="fw-bold mb-2">Подписка на новости</h6>
<form class="d-flex">
<input type="email" class="form-control me-2" placeholder="Ваш email">
<button class="btn btn-primary" type="submit">Подписаться</button>
</form>
</div>
</div>
</div>
<hr class="my-4">
<div class="row align-items-center">
<div class="col-md-6">
<p class="mb-0 text-muted">&copy; 2024 <%= siteName %>. Все права защищены.</p>
</div>
<div class="col-md-6 text-md-end">
<small class="text-muted">
Сделано с <i class="fas fa-heart text-danger"></i> для туризма по Корее
</small>
</div>
</div>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- AOS Animation -->
<script src="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.js"></script>
<!-- Swiper JS -->
<script src="https://cdn.jsdelivr.net/npm/swiper@10/swiper-bundle.min.js"></script>
<!-- Custom JS -->
<script src="/js/main.js"></script>
<!-- Initialize AOS -->
<script>
AOS.init({
duration: 800,
once: true,
offset: 100
});
</script>
<% if (typeof pageScripts !== 'undefined') { %>
<%- pageScripts %>
<% } %>
</body>
</html>

289
views/routes/detail.ejs Normal file
View File

@@ -0,0 +1,289 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white">
<div class="container py-5">
<div class="row align-items-center">
<div class="col-lg-8">
<h1 class="display-4 fw-bold mb-3"><%= route.title %></h1>
<p class="lead mb-4"><%= route.description %></p>
<div class="d-flex flex-wrap gap-2 mb-4">
<% if (route.type === 'city') { %>
<span class="badge bg-info fs-6">
<i class="fas fa-city me-1"></i>Городской тур
</span>
<% } else if (route.type === 'mountain') { %>
<span class="badge bg-success fs-6">
<i class="fas fa-mountain me-1"></i>Горный поход
</span>
<% } else if (route.type === 'fishing') { %>
<span class="badge bg-info fs-6">
<i class="fas fa-fish me-1"></i>Рыбалка
</span>
<% } %>
<span class="badge <%= route.difficulty_level === 'easy' ? 'bg-success' : route.difficulty_level === 'moderate' ? 'bg-warning' : 'bg-danger' %> fs-6">
<%= route.difficulty_level === 'easy' ? 'Легко' : route.difficulty_level === 'moderate' ? 'Средне' : 'Сложно' %>
</span>
</div>
</div>
<div class="col-lg-4">
<div class="bg-white text-dark p-4 rounded shadow">
<h4 class="text-primary mb-3">Детали тура</h4>
<div class="mb-3">
<strong class="d-block">Цена</strong>
<span class="h4 text-primary">₩<%= formatCurrency(route.price) %></span>
</div>
<div class="mb-3">
<strong class="d-block">Продолжительность</strong>
<span><%= route.duration %> дней</span>
</div>
<div class="mb-3">
<strong class="d-block">Максимум участников</strong>
<span><%= route.max_group_size %> человек</span>
</div>
<a href="/contact" class="btn btn-primary w-100">
<i class="fas fa-calendar-plus me-1"></i>Забронировать
</a>
</div>
</div>
</div>
</div>
</section>
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
<!-- Route Image -->
<%
let placeholderImage = '/images/placeholder.jpg';
if (route.type === 'city') {
placeholderImage = '/images/city-tour-placeholder.webp';
} else if (route.type === 'mountain') {
placeholderImage = '/images/mountain-placeholder.jpg';
} else if (route.type === 'fishing') {
placeholderImage = '/images/fish-placeholder.jpg';
}
%>
<% if (route.image_url && route.image_url.trim()) { %>
<img src="<%= route.image_url %>" class="img-fluid rounded mb-4" alt="<%= route.title %>">
<% } else { %>
<img src="<%= placeholderImage %>" class="img-fluid rounded mb-4" alt="<%= route.title %>">
<% } %>
<!-- Route Content -->
<div class="mb-5">
<h2 class="mb-3">Описание маршрута</h2>
<div class="content">
<%= route.content || 'Подробное описание маршрута будет доступно в ближайшее время.' %>
</div>
</div>
<!-- Included Services -->
<% if (route.included_services && route.included_services.length > 0) { %>
<div class="mb-5">
<h3 class="mb-3">Что включено</h3>
<div class="row">
<% route.included_services.forEach(service => { %>
<div class="col-md-6 mb-2">
<i class="fas fa-check text-success me-2"></i><%= service %>
</div>
<% }); %>
</div>
</div>
<% } %>
<!-- Reviews Section -->
<div class="mb-5">
<h3 class="mb-4">Отзывы (<%= reviews.length %>)</h3>
<!-- Review Form -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Оставить отзыв</h5>
</div>
<div class="card-body">
<form id="reviewForm">
<input type="hidden" name="route_id" value="<%= route.id %>">
<% if (route.guide_id) { %>
<input type="hidden" name="guide_id" value="<%= route.guide_id %>">
<% } %>
<div class="row">
<div class="col-md-6 mb-3">
<label for="customer_name" class="form-label">Ваше имя *</label>
<input type="text" class="form-control" id="customer_name" name="customer_name" required>
</div>
<div class="col-md-6 mb-3">
<label for="customer_email" class="form-label">Email</label>
<input type="email" class="form-control" id="customer_email" name="customer_email">
</div>
</div>
<div class="mb-3">
<label for="rating" class="form-label">Оценка *</label>
<div class="star-rating">
<input type="radio" id="star5" name="rating" value="5">
<label for="star5" title="Отлично"><i class="fas fa-star"></i></label>
<input type="radio" id="star4" name="rating" value="4">
<label for="star4" title="Хорошо"><i class="fas fa-star"></i></label>
<input type="radio" id="star3" name="rating" value="3">
<label for="star3" title="Нормально"><i class="fas fa-star"></i></label>
<input type="radio" id="star2" name="rating" value="2">
<label for="star2" title="Плохо"><i class="fas fa-star"></i></label>
<input type="radio" id="star1" name="rating" value="1">
<label for="star1" title="Очень плохо"><i class="fas fa-star"></i></label>
</div>
</div>
<div class="mb-3">
<label for="comment" class="form-label">Комментарий *</label>
<textarea class="form-control" id="comment" name="comment" rows="4"
placeholder="Поделитесь впечатлениями о туре..." required></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane me-1"></i>Отправить отзыв
</button>
</form>
</div>
</div>
<!-- Reviews List -->
<% if (reviews.length === 0) { %>
<p class="text-muted">Пока нет отзывов об этом туре. Будьте первым!</p>
<% } else { %>
<% reviews.forEach(review => { %>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h6 class="mb-0"><%= review.customer_name %></h6>
<div>
<%- generateStars(review.rating) %>
</div>
</div>
<p class="card-text"><%= review.comment %></p>
<small class="text-muted"><%= formatDate(review.created_at) %></small>
</div>
</div>
<% }); %>
<% } %>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Guide Info -->
<% if (route.guide_name) { %>
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title">Ваш гид</h5>
<% if (route.guide_image) { %>
<img src="<%= route.guide_image %>" class="rounded-circle mb-3" width="80" height="80" style="object-fit: cover;" alt="<%= route.guide_name %>">
<% } %>
<h6><%= route.guide_name %></h6>
<% if (route.guide_bio) { %>
<p class="text-muted small"><%= route.guide_bio %></p>
<% } %>
</div>
</div>
<% } %>
<!-- Contact Card -->
<div class="card">
<div class="card-body">
<h5 class="card-title">Нужна помощь?</h5>
<p class="card-text">Свяжитесь с нами для получения дополнительной информации о туре.</p>
<div class="mb-3">
<strong class="d-block">Телефон</strong>
<span>+7 (495) 123-45-67</span>
</div>
<div class="mb-3">
<strong class="d-block">Email</strong>
<span>info@koreatour.ru</span>
</div>
<a href="/contact" class="btn btn-outline-primary w-100">
<i class="fas fa-envelope me-1"></i>Написать нам
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Similar Tours -->
<section class="bg-light py-5">
<div class="container">
<h2 class="text-center mb-5">Похожие туры</h2>
<div class="text-center">
<a href="/routes?type=<%= route.type %>" class="btn btn-primary">
Посмотреть все туры категории
</a>
</div>
</div>
</section>
<style>
.star-rating {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
}
.star-rating input {
display: none;
}
.star-rating label {
color: #ddd;
font-size: 1.5rem;
margin: 0 2px;
cursor: pointer;
transition: color 0.2s;
}
.star-rating input:checked ~ label,
.star-rating label:hover,
.star-rating label:hover ~ label {
color: #ffc107;
}
</style>
<script>
// Review form submission
document.getElementById('reviewForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData);
// Validate rating
if (!data.rating) {
alert('Пожалуйста, выберите оценку');
return;
}
try {
const response = await fetch('/api/reviews', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert(result.message);
this.reset();
// Optionally reload the page to show new review
// location.reload();
} else {
alert(result.message);
}
} catch (error) {
console.error('Error submitting review:', error);
alert('Произошла ошибка при отправке отзыва');
}
});
</script>

131
views/routes/index.ejs Normal file
View File

@@ -0,0 +1,131 @@
<!-- Hero Section -->
<section class="hero-section bg-primary text-white text-center py-5">
<div class="container">
<div class="row align-items-center">
<div class="col-lg-8 mx-auto">
<h1 class="display-4 fw-bold mb-4">Туры по Корее</h1>
<p class="lead mb-4">Откройте для себя удивительную Корею с нашими профессиональными гидами</p>
<!-- Filter Buttons -->
<div class="btn-group mb-4" role="group">
<a href="/routes" class="btn <%= currentType === 'all' ? 'btn-light' : 'btn-outline-light' %>">
<i class="fas fa-list me-1"></i>Все туры
</a>
<a href="/routes?type=city" class="btn <%= currentType === 'city' ? 'btn-light' : 'btn-outline-light' %>">
<i class="fas fa-city me-1"></i>Городские туры
</a>
<a href="/routes?type=mountain" class="btn <%= currentType === 'mountain' ? 'btn-light' : 'btn-outline-light' %>">
<i class="fas fa-mountain me-1"></i>Горные походы
</a>
<a href="/routes?type=fishing" class="btn <%= currentType === 'fishing' ? 'btn-light' : 'btn-outline-light' %>">
<i class="fas fa-fish me-1"></i>Морская рыбалка
</a>
</div>
</div>
</div>
</div>
</section>
<!-- Routes Grid -->
<section class="py-5">
<div class="container">
<% if (routes.length === 0) { %>
<div class="text-center py-5">
<i class="fas fa-map text-muted" style="font-size: 4rem;"></i>
<h3 class="mt-3 text-muted">Туры не найдены</h3>
<p class="text-muted">В данной категории пока нет доступных туров.</p>
<a href="/routes" class="btn btn-primary">Посмотреть все туры</a>
</div>
<% } else { %>
<div class="row">
<% routes.forEach(route => { %>
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 shadow-sm">
<%
let placeholderImage = '/images/placeholder.jpg';
if (route.type === 'city') {
placeholderImage = '/images/city-tour-placeholder.webp';
} else if (route.type === 'mountain') {
placeholderImage = '/images/mountain-placeholder.jpg';
} else if (route.type === 'fishing') {
placeholderImage = '/images/fish-placeholder.jpg';
}
%>
<% if (route.image_url && route.image_url.trim()) { %>
<img src="<%= route.image_url %>" class="card-img-top" alt="<%= route.title %>" style="height: 250px; object-fit: cover;">
<% } else { %>
<img src="<%= placeholderImage %>" class="card-img-top" alt="<%= route.title %>" style="height: 250px; object-fit: cover;">
<% } %>
<!-- Featured Badge -->
<% if (route.is_featured) { %>
<span class="badge bg-warning text-dark position-absolute top-0 start-0 m-2">
<i class="fas fa-star me-1"></i>Рекомендуем
</span>
<% } %>
<div class="card-body d-flex flex-column">
<div class="mb-2">
<% if (route.type === 'city') { %>
<span class="badge bg-info text-dark">
<i class="fas fa-city me-1"></i>Городской тур
</span>
<% } else if (route.type === 'mountain') { %>
<span class="badge bg-success">
<i class="fas fa-mountain me-1"></i>Горный поход
</span>
<% } else if (route.type === 'fishing') { %>
<span class="badge bg-primary">
<i class="fas fa-fish me-1"></i>Рыбалка
</span>
<% } %>
<% if (route.difficulty_level) { %>
<span class="badge <%= route.difficulty_level === 'easy' ? 'bg-success' : route.difficulty_level === 'moderate' ? 'bg-warning' : 'bg-danger' %>">
<%= route.difficulty_level === 'easy' ? 'Легко' : route.difficulty_level === 'moderate' ? 'Средне' : 'Сложно' %>
</span>
<% } %>
</div>
<h5 class="card-title"><%= route.title %></h5>
<p class="card-text text-muted flex-grow-1"><%= route.description %></p>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<strong class="text-primary h5">₩<%= formatCurrency(route.price) %></strong>
</div>
<small class="text-muted">
<i class="fas fa-clock me-1"></i><%= route.duration %> дн.
</small>
</div>
<% if (route.guide_name) { %>
<p class="text-muted mb-2">
<i class="fas fa-user-tie me-1"></i>Гид: <%= route.guide_name %>
</p>
<% } %>
<a href="/routes/<%= route.id %>" class="btn btn-primary w-100">
<i class="fas fa-info-circle 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>