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
7
.adminjs/bundle.js
Normal 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
@@ -0,0 +1 @@
|
||||
AdminJS.UserComponents = {}
|
||||
72
.env.example
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
75
database/migrate.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
51
package.json
Normal 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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
public/images/city-tour-placeholder.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
44
public/images/default-article.jpg
Normal 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 |
44
public/images/default-tour.jpg
Normal 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 |
BIN
public/images/fish-placeholder.jpg
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
44
public/images/korea-hero.jpg
Normal 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 |
BIN
public/images/mountain-placeholder.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
public/images/placeholder.jpg
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
43
public/images/placeholders/default-guide.svg
Normal 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 |
44
public/images/placeholders/default-tour.svg
Normal 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
@@ -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">×</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
@@ -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
@@ -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);
|
||||
542
src/config/adminjs-simple.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">© 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
@@ -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
@@ -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>
|
||||