first commit
BIN
.env.example
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
#cursor
|
||||||
|
.cursor
|
||||||
365
.project-rules.json
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
{
|
||||||
|
"project": "CMS SIMS Swiss",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"rules": {
|
||||||
|
"directory_structure": {
|
||||||
|
"data": {
|
||||||
|
"description": "Chứa dữ liệu JSON gốc",
|
||||||
|
"naming": "<feature>.json",
|
||||||
|
"required_fields": ["updatedAt"],
|
||||||
|
"file_structure": {
|
||||||
|
"sections": "Các section của trang",
|
||||||
|
"images": "Đường dẫn bắt đầu bằng /uploads/<feature>/",
|
||||||
|
"validation": "Tất cả trường bắt buộc phải có giá trị"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"description": "Mongoose models",
|
||||||
|
"naming": "<Feature>.js (PascalCase)",
|
||||||
|
"required_fields": ["updatedAt"],
|
||||||
|
"schema_rules": {
|
||||||
|
"types": "Định nghĩa rõ type cho mỗi trường",
|
||||||
|
"required": "Đánh dấu trường bắt buộc với required: true",
|
||||||
|
"timestamps": "Sử dụng updatedAt tự động"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"description": "Migration scripts",
|
||||||
|
"naming": "migrate-<feature>.js",
|
||||||
|
"required_functions": {
|
||||||
|
"validate": "validate<Feature>Data()",
|
||||||
|
"migrate": "migrate<Feature>Data()"
|
||||||
|
},
|
||||||
|
"error_handling": {
|
||||||
|
"validation": "Kiểm tra đầy đủ trường và cấu trúc",
|
||||||
|
"logging": "Log chi tiết quá trình migrate",
|
||||||
|
"cleanup": "Xóa dữ liệu cũ trước khi migrate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"controllers": {
|
||||||
|
"description": "Logic xử lý",
|
||||||
|
"naming": "<feature>Controller.js",
|
||||||
|
"required_methods": {
|
||||||
|
"index": "GET /admin/<feature>",
|
||||||
|
"update": "POST /admin/<feature>/update",
|
||||||
|
"delete": "DELETE /admin/<feature>"
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"validation": "Validate input data",
|
||||||
|
"flash_messages": "Thông báo kết quả cho user",
|
||||||
|
"error_handling": "Try-catch cho async functions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"structure": {
|
||||||
|
"admin": "views/admin/<feature>/index.ejs",
|
||||||
|
"page": "views/page/<feature>/index.ejs"
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"layout": "Sử dụng layout chung",
|
||||||
|
"forms": "Form validation và error handling",
|
||||||
|
"buttons": "Consistent button styling",
|
||||||
|
"messages": "Flash messages display"
|
||||||
|
},
|
||||||
|
"ui_rules": {
|
||||||
|
"responsive": "Responsive design cho mọi màn hình",
|
||||||
|
"loading": "Loading states cho async operations",
|
||||||
|
"error": "Error states và messages",
|
||||||
|
"success": "Success feedback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"naming_conventions": {
|
||||||
|
"files": "kebab-case (migrate-admissions.js)",
|
||||||
|
"models": "PascalCase (Admissions)",
|
||||||
|
"functions": "camelCase (validateData)",
|
||||||
|
"routes": "kebab-case (/admin/about-us)",
|
||||||
|
"variables": "camelCase (userData)"
|
||||||
|
},
|
||||||
|
"validation_rules": {
|
||||||
|
"frontend": {
|
||||||
|
"forms": "Client-side validation",
|
||||||
|
"file_upload": "Kiểm tra type và size"
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"model": "Mongoose schema validation",
|
||||||
|
"input": "Sanitize và validate input",
|
||||||
|
"file": "Validate file types và limits"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_handling": {
|
||||||
|
"try_catch": "Bắt lỗi cho async functions",
|
||||||
|
"messages": {
|
||||||
|
"user": "Flash messages cho user feedback",
|
||||||
|
"log": "Console.log cho development",
|
||||||
|
"production": "Error logging service cho production"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"input": "Validate và sanitize tất cả input",
|
||||||
|
"file_upload": {
|
||||||
|
"types": "Chỉ cho phép định dạng an toàn",
|
||||||
|
"size": "Giới hạn kích thước file",
|
||||||
|
"storage": "Lưu trữ an toàn với unique names"
|
||||||
|
},
|
||||||
|
"access": "Phân quyền admin/user"
|
||||||
|
},
|
||||||
|
"development_workflow": {
|
||||||
|
"steps": [
|
||||||
|
"1. Tạo JSON data mẫu",
|
||||||
|
"2. Tạo Mongoose model",
|
||||||
|
"3. Viết migration script",
|
||||||
|
"4. Tạo controller",
|
||||||
|
"5. Tạo admin view",
|
||||||
|
"6. Tạo public view",
|
||||||
|
"7. Test toàn bộ flow",
|
||||||
|
"8. Review code"
|
||||||
|
],
|
||||||
|
"testing": {
|
||||||
|
"data": "Verify JSON structure",
|
||||||
|
"model": "Test CRUD operations",
|
||||||
|
"migration": "Test với dữ liệu mẫu",
|
||||||
|
"ui": "Test responsive và interactions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"routing_rules": {
|
||||||
|
"admin_routes": {
|
||||||
|
"file": "routes/admin.js",
|
||||||
|
"structure": {
|
||||||
|
"base": "/admin/<feature>",
|
||||||
|
"required_routes": {
|
||||||
|
"index": "GET /<feature>",
|
||||||
|
"update": "POST /<feature>/update"
|
||||||
|
},
|
||||||
|
"middleware": {
|
||||||
|
"auth": "ensureAuthenticated",
|
||||||
|
"upload": "upload.single('file')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"controller_import": "const <feature>Controller = require('../controllers/<feature>Controller')",
|
||||||
|
"route_definition": "router.<method>('/<path>', middleware, controller.<action>)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"api_routes": {
|
||||||
|
"file": "routes/index.js",
|
||||||
|
"structure": {
|
||||||
|
"base": "/api/<feature>",
|
||||||
|
"required_routes": {
|
||||||
|
"get": "GET /api/<feature>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"controller_import": "const <feature>Controller = require('../controllers/<feature>Controller')",
|
||||||
|
"route_definition": "router.get('/api/<feature>', controller.api)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ui_standards": {
|
||||||
|
"admin_views": {
|
||||||
|
"layout": {
|
||||||
|
"header": {
|
||||||
|
"title": "Page title with breadcrumb",
|
||||||
|
"actions": "Primary actions (Save, Reset)"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"structure": "Use nav-tabs for section navigation",
|
||||||
|
"content": "Each tab contains related content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"section_cards": {
|
||||||
|
"header": {
|
||||||
|
"title": "Section title",
|
||||||
|
"actions": "Add/Remove buttons"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"form_groups": "Grouped form elements",
|
||||||
|
"validation": "Client-side validation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_management": {
|
||||||
|
"header": {
|
||||||
|
"position": "Action buttons in card header",
|
||||||
|
"buttons": {
|
||||||
|
"add": "Add new item button",
|
||||||
|
"remove": "Remove item button"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"container": {
|
||||||
|
"structure": "Consistent padding and spacing",
|
||||||
|
"style": "Border and background for separation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form_elements": {
|
||||||
|
"layout": "Bootstrap grid system",
|
||||||
|
"spacing": "Consistent margins and padding",
|
||||||
|
"validation": "Required field indicators",
|
||||||
|
"feedback": "Error and success states"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"controller_standards": {
|
||||||
|
"required_methods": {
|
||||||
|
"index": {
|
||||||
|
"purpose": "Render admin view with data",
|
||||||
|
"response": "Render with layout and data"
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"purpose": "Handle form submission",
|
||||||
|
"validation": "Validate input data",
|
||||||
|
"response": "Redirect with flash message"
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"purpose": "Serve API data",
|
||||||
|
"response": "JSON response with data"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error_handling": {
|
||||||
|
"try_catch": "Wrap async operations",
|
||||||
|
"validation": "Input data validation",
|
||||||
|
"messages": "Flash messages for feedback"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message_standards": {
|
||||||
|
"language": "English",
|
||||||
|
"flash_messages": {
|
||||||
|
"success": {
|
||||||
|
"create": "Successfully created [resource]",
|
||||||
|
"update": "Successfully updated [resource]",
|
||||||
|
"delete": "Successfully deleted [resource]",
|
||||||
|
"upload": "Successfully uploaded [resource]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"not_found": "[Resource] not found",
|
||||||
|
"invalid_input": "Invalid input data",
|
||||||
|
"validation": "Please check your input and try again",
|
||||||
|
"upload": "Failed to upload [resource]",
|
||||||
|
"server": "An error occurred. Please try again"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"processing": "Processing your request",
|
||||||
|
"no_changes": "No changes detected",
|
||||||
|
"confirm_delete": "Are you sure you want to delete this [resource]?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation_messages": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"invalid_format": "Invalid format",
|
||||||
|
"min_length": "Must be at least [n] characters",
|
||||||
|
"max_length": "Must not exceed [n] characters",
|
||||||
|
"invalid_type": "Invalid type. Expected [type]",
|
||||||
|
"unique": "This [field] already exists"
|
||||||
|
},
|
||||||
|
"api_responses": {
|
||||||
|
"success": {
|
||||||
|
"200": "Request successful",
|
||||||
|
"201": "Resource created successfully",
|
||||||
|
"204": "Resource deleted successfully"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"400": "Bad request - Invalid input",
|
||||||
|
"401": "Unauthorized - Please login",
|
||||||
|
"403": "Forbidden - Insufficient permissions",
|
||||||
|
"404": "Resource not found",
|
||||||
|
"500": "Internal server error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"format_rules": {
|
||||||
|
"capitalization": "First letter capitalized",
|
||||||
|
"punctuation": "End with proper punctuation",
|
||||||
|
"placeholders": "Use [placeholder] format",
|
||||||
|
"consistency": "Use same message for same actions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_management_standards": {
|
||||||
|
"form_data_handling": {
|
||||||
|
"preparation": {
|
||||||
|
"hidden_inputs": {
|
||||||
|
"purpose": "Store JSON data for complex sections",
|
||||||
|
"naming": "<section>Json (e.g. bannerJson, overviewJson)",
|
||||||
|
"structure": "Hidden input for each major section"
|
||||||
|
},
|
||||||
|
"data_collection": {
|
||||||
|
"method": "Collect all form data before submission",
|
||||||
|
"validation": "Validate required fields and data types",
|
||||||
|
"structure": "Group data by logical sections"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json_conversion": {
|
||||||
|
"sections": {
|
||||||
|
"main_data": "Primary section information (title, description)",
|
||||||
|
"items_data": "Array of items with consistent structure",
|
||||||
|
"media_data": "Image paths and media information"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required_fields": "Check all required fields before conversion",
|
||||||
|
"data_types": "Ensure correct data types for each field",
|
||||||
|
"structure": "Maintain consistent JSON structure"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save_process": {
|
||||||
|
"client_side": {
|
||||||
|
"data_collection": {
|
||||||
|
"form_data": "Collect all form inputs",
|
||||||
|
"file_data": "Handle file uploads separately",
|
||||||
|
"validation": "Client-side validation before submit"
|
||||||
|
},
|
||||||
|
"json_preparation": {
|
||||||
|
"structure": "Prepare structured JSON data",
|
||||||
|
"hidden_fields": "Update hidden JSON inputs",
|
||||||
|
"validation": "Validate JSON structure"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server_side": {
|
||||||
|
"data_processing": {
|
||||||
|
"validation": "Server-side validation of all data",
|
||||||
|
"sanitization": "Clean and sanitize input data",
|
||||||
|
"transformation": "Transform data to required format"
|
||||||
|
},
|
||||||
|
"file_handling": {
|
||||||
|
"upload": "Process and store uploaded files",
|
||||||
|
"paths": "Update file paths in data",
|
||||||
|
"cleanup": "Remove unused files"
|
||||||
|
},
|
||||||
|
"database_update": {
|
||||||
|
"transaction": "Use transactions for complex updates",
|
||||||
|
"validation": "Validate data before saving",
|
||||||
|
"backup": "Backup existing data before update"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response_handling": {
|
||||||
|
"success": {
|
||||||
|
"redirect": "Redirect to appropriate page",
|
||||||
|
"message": "Display success message via toast",
|
||||||
|
"data_refresh": "Refresh displayed data"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"validation": "Show specific validation errors",
|
||||||
|
"server": "Handle and display server errors",
|
||||||
|
"recovery": "Maintain form data for recovery"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_structure": {
|
||||||
|
"sections": {
|
||||||
|
"main": {
|
||||||
|
"title": "Section title field",
|
||||||
|
"description": "Section description field",
|
||||||
|
"image": "Main image path if applicable"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"structure": "Array of related items",
|
||||||
|
"required_fields": ["title", "description"],
|
||||||
|
"optional_fields": ["image", "order"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"relationships": {
|
||||||
|
"parent_child": "Maintain parent-child relationships",
|
||||||
|
"ordering": "Maintain item order in lists",
|
||||||
|
"references": "Handle references between sections"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
||||||
|
|
||||||
|
WORKDIR /home/node/app
|
||||||
|
|
||||||
|
COPY --chown=node:node package*.json ./
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD [ "npm", "start" ]
|
||||||
185
README.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Content Management System - GGCamp
|
||||||
|
|
||||||
|
## Mô Tả Dự Án
|
||||||
|
|
||||||
|
Hệ thống quản lý nội dung (CMS) cho Swiss International Management School (SIMS), cung cấp giải pháp quản lý và hiển thị thông tin học viện một cách chuyên nghiệp và hiệu quả.
|
||||||
|
|
||||||
|
## Yêu Cầu Hệ Thống
|
||||||
|
|
||||||
|
- Node.js (v16 hoặc cao hơn)
|
||||||
|
- MongoDB (v4.4 hoặc cao hơn)
|
||||||
|
- npm (v8 hoặc cao hơn)
|
||||||
|
|
||||||
|
## Cài Đặt Ban Đầu
|
||||||
|
|
||||||
|
### 1. Clone Dự Án
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/sims-cms.git
|
||||||
|
cd sims-cms
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Cài Đặt Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Cấu Hình Môi Trường
|
||||||
|
|
||||||
|
Tạo file `.env` trong thư mục gốc với các cấu hình sau:
|
||||||
|
|
||||||
|
```
|
||||||
|
PORT=3000
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/SIMS
|
||||||
|
BACKEND_URL=http://localhost:3000
|
||||||
|
FRONTEND_URL=http://localhost:8080
|
||||||
|
SESSION_SECRET=your_secret_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hệ Thống Migration
|
||||||
|
|
||||||
|
Hệ thống migration sử dụng cơ chế tracking để quản lý các migration đã chạy, tương tự Laravel. Tất cả các file migration được tự động phát hiện trong thư mục `scripts/` (trừ các file quản lý migration).
|
||||||
|
|
||||||
|
### Các Lệnh Migration
|
||||||
|
|
||||||
|
#### 1. Chạy Migrations Chưa Chạy
|
||||||
|
|
||||||
|
Chạy tất cả các migrations chưa được thực thi (có tracking, tự động skip migrations đã chạy):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tính năng:**
|
||||||
|
|
||||||
|
- Tự động phát hiện tất cả file migration trong thư mục `scripts/`
|
||||||
|
- Chỉ chạy các migrations chưa được thực thi
|
||||||
|
- Tự động skip migrations đã chạy
|
||||||
|
- Tracking migrations trong database với batch number
|
||||||
|
- Hiển thị bảng kết quả với trạng thái: DONE, SKIPPED, FAIL
|
||||||
|
|
||||||
|
#### 2. Chạy Lại Tất Cả Migrations (Fresh)
|
||||||
|
|
||||||
|
Xóa tất cả tracking và chạy lại tất cả migrations từ đầu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate-fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lưu ý:** Lệnh này sẽ xóa tất cả records trong collection `migrations` và chạy lại tất cả migrations từ đầu. Sử dụng cẩn thận trong môi trường production.
|
||||||
|
|
||||||
|
#### 3. Xem Trạng Thái Migrations
|
||||||
|
|
||||||
|
Kiểm tra trạng thái của tất cả migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate-status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hiển thị:**
|
||||||
|
|
||||||
|
- Danh sách tất cả migrations
|
||||||
|
- Trạng thái: Ran hoặc Pending
|
||||||
|
- Batch number và thời gian chạy (nếu đã chạy)
|
||||||
|
- Tóm tắt: số lượng migrations đã chạy và chưa chạy
|
||||||
|
|
||||||
|
#### 4. Rollback Migrations
|
||||||
|
|
||||||
|
**Rollback một migration cụ thể:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate-rollback <migration-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rollback batch cuối cùng:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate-rollback --batch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ví dụ:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate-rollback 2025_12_02_114125_home
|
||||||
|
npm run migrate-rollback --batch
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Tạo Migration Mới
|
||||||
|
|
||||||
|
Tạo file migration mới với format timestamp tự động:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run make-migration <migration-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ví dụ:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run make-migration create_users_table
|
||||||
|
```
|
||||||
|
|
||||||
|
File migration sẽ được tạo với format: `YYYY_MM_DD_HHMMSS_<migration-name>.js`
|
||||||
|
|
||||||
|
**Template migration:**
|
||||||
|
|
||||||
|
- Tự động kết nối database
|
||||||
|
- Có sẵn cấu trúc cơ bản
|
||||||
|
- Tự động disconnect sau khi hoàn thành
|
||||||
|
|
||||||
|
### Quy Tắc Migration
|
||||||
|
|
||||||
|
1. **Format tên file:** `YYYY_MM_DD_HHMMSS_migration_name.js`
|
||||||
|
2. **Tự động phát hiện:** Tất cả file `.js` trong thư mục `scripts/` (trừ các file quản lý)
|
||||||
|
3. **Tracking:** Migrations được track trong collection `migrations` của MongoDB
|
||||||
|
4. **Batch system:** Migrations được nhóm theo batch để dễ quản lý
|
||||||
|
5. **Thứ tự chạy:** Migrations được sắp xếp theo tên (alphabetical) để đảm bảo thứ tự nhất quán
|
||||||
|
|
||||||
|
## Chạy Ứng Dụng
|
||||||
|
|
||||||
|
### Chế Độ Phát Triển
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Ứng dụng sẽ chạy trên `http://localhost:3000`
|
||||||
|
|
||||||
|
### Chế Độ Sản Xuất
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cấu Trúc Thư Mục
|
||||||
|
|
||||||
|
```
|
||||||
|
sims-cms/
|
||||||
|
│
|
||||||
|
├── config/ # Cấu hình ứng dụng
|
||||||
|
├── controllers/ # Logic xử lý
|
||||||
|
├── data/ # Dữ liệu JSON gốc
|
||||||
|
├── middleware/ # Các middleware
|
||||||
|
├── models/ # Mongoose models
|
||||||
|
├── public/ # Tài nguyên tĩnh
|
||||||
|
├── routes/ # Định nghĩa routes
|
||||||
|
├── scripts/ # Migration scripts
|
||||||
|
├── utils/ # Các hàm tiện ích
|
||||||
|
└── views/ # Giao diện EJS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các Lệnh Quan Trọng
|
||||||
|
|
||||||
|
### Ứng Dụng
|
||||||
|
|
||||||
|
- `npm run dev`: Chạy ứng dụng ở chế độ phát triển (với nodemon)
|
||||||
|
- `npm start`: Chạy ứng dụng ở chế độ sản xuất
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- `npm run migrate`: Chạy tất cả migrations chưa chạy
|
||||||
|
- `npm run migrate-fresh`: Chạy lại tất cả migrations từ đầu
|
||||||
|
- `npm run migrate-status`: Xem trạng thái tất cả migrations
|
||||||
|
- `npm run migrate-rollback <name>`: Rollback một migration cụ thể
|
||||||
|
- `npm run migrate-rollback --batch`: Rollback batch cuối cùng
|
||||||
|
- `npm run make-migration <name>`: Tạo migration file mới
|
||||||
83
config/database.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const actionSchema = new mongoose.Schema({
|
||||||
|
updated: Number,
|
||||||
|
});
|
||||||
|
const Action = mongoose.model("Action", actionSchema);
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema({ name: String });
|
||||||
|
|
||||||
|
async function updateAction() {
|
||||||
|
try {
|
||||||
|
await Action.updateOne({}, { $set: { updated: 1 } }, { upsert: true });
|
||||||
|
|
||||||
|
console.log("✅ Cập nhật thành công");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Lỗi cập nhật:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
const conn = await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
console.log(`✅ MongoDB Connected:[ ${conn.connection.host} ]`);
|
||||||
|
|
||||||
|
// Lấy db sau khi connect thành công
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
|
||||||
|
// admin is the database admin interface used to run server commands
|
||||||
|
const admin = db.admin();
|
||||||
|
const isMaster = await admin.command({ isMaster: 1 });
|
||||||
|
|
||||||
|
// Only create a ChangeStream when connected to a replica set.
|
||||||
|
// On standalone servers the $changeStream aggregation stage isn't supported
|
||||||
|
// and attempting to watch() will emit an 'error' event that can crash the
|
||||||
|
// process if not handled. Guard and add defensive error handling.
|
||||||
|
if (isMaster && isMaster.setName) {
|
||||||
|
try {
|
||||||
|
const changeStream = db.watch([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
"ns.coll": { $ne: "actions" }, // bỏ qua collection "actions"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
changeStream.on("change", async (change) => {
|
||||||
|
try {
|
||||||
|
console.log("📢 Change detected:", change);
|
||||||
|
await updateAction();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error handling change event:", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defensive: listen for errors so they don't bubble as unhandled
|
||||||
|
changeStream.on("error", (err) => {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ ChangeStream error — disabling change stream:",
|
||||||
|
err && err.message ? err.message : err,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
changeStream.close();
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// If watch() throws synchronously (older drivers/servers) skip it.
|
||||||
|
console.log(
|
||||||
|
"ℹ️ ChangeStream not initialized (watch() failed):",
|
||||||
|
e.message || e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("ℹ️ ChangeStream skipped (no replica set detected)");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = connectDB;
|
||||||
161
controllers/aboutController.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||||
|
const About = require('../models/about');
|
||||||
|
|
||||||
|
// Get about data from MongoDB
|
||||||
|
const getAboutData = async () => {
|
||||||
|
const about = await About.findOne().sort({ updatedAt: -1 });
|
||||||
|
|
||||||
|
// Trả về object rỗng với cấu trúc cơ bản nếu không có dữ liệu
|
||||||
|
if (!about) {
|
||||||
|
return {
|
||||||
|
banner: {
|
||||||
|
image: '',
|
||||||
|
title: '',
|
||||||
|
text: ''
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: '',
|
||||||
|
paragraphs: [],
|
||||||
|
list_items: [],
|
||||||
|
button: {
|
||||||
|
text: '',
|
||||||
|
url: ''
|
||||||
|
},
|
||||||
|
image: '',
|
||||||
|
quote: {
|
||||||
|
mark_image: '',
|
||||||
|
title: '',
|
||||||
|
text: '',
|
||||||
|
author: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
background_image: '',
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
education: {
|
||||||
|
images: {
|
||||||
|
student1: '',
|
||||||
|
student2: ''
|
||||||
|
},
|
||||||
|
subtitle: '',
|
||||||
|
title: '',
|
||||||
|
text: ''
|
||||||
|
},
|
||||||
|
advantages: {
|
||||||
|
title: '',
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
academic_board: {
|
||||||
|
title: '',
|
||||||
|
members: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return about;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display about management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getAboutData();
|
||||||
|
res.render('admin/about', {
|
||||||
|
title: 'About Management',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading about data');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update about data
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Lấy document hiện tại từ MongoDB
|
||||||
|
const currentData = await getAboutData();
|
||||||
|
|
||||||
|
// Danh sách các section cần cập nhật
|
||||||
|
const sections = ['banner', 'about', 'values', 'education', 'advantages', 'academic_board'];
|
||||||
|
const errors = [];
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// Tạo đối tượng dữ liệu mới dựa trên dữ liệu hiện tại
|
||||||
|
const updatedData = { ...currentData.toObject() };
|
||||||
|
|
||||||
|
// Xử lý từng section
|
||||||
|
sections.forEach(section => {
|
||||||
|
try {
|
||||||
|
// Kiểm tra nếu section không được gửi lên
|
||||||
|
if (!req.body[section]) {
|
||||||
|
console.warn(`No data for section: ${section}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse dữ liệu JSON từ form
|
||||||
|
const newSectionData = JSON.parse(req.body[section]);
|
||||||
|
|
||||||
|
// So sánh dữ liệu mới với dữ liệu hiện tại
|
||||||
|
const currentSectionData = currentData[section];
|
||||||
|
const sectionHasChanges = JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||||
|
|
||||||
|
// Nếu có thay đổi, cập nhật vào đối tượng dữ liệu mới
|
||||||
|
if (sectionHasChanges) {
|
||||||
|
updatedData[section] = newSectionData;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing section ${section}:`, error);
|
||||||
|
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nếu có lỗi, thông báo và chuyển hướng
|
||||||
|
if (errors.length > 0) {
|
||||||
|
req.flash('error_msg', `Data processing error: ${errors[0]}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/about'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu không có thay đổi, thông báo và chuyển hướng
|
||||||
|
if (!hasChanges) {
|
||||||
|
req.flash('info_msg', 'No changes were made');
|
||||||
|
return req.session.save(() => res.redirect('/admin/about'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Cập nhật hoặc tạo mới document trong MongoDB
|
||||||
|
if (currentData._id) {
|
||||||
|
await About.findByIdAndUpdate(currentData._id, updatedData, { new: true });
|
||||||
|
} else {
|
||||||
|
await About.create(updatedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success notification and redirect
|
||||||
|
req.flash('success_msg', 'About data updated successfully');
|
||||||
|
return req.session.save(() => res.redirect('/admin/about'));
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Database error:', dbError);
|
||||||
|
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/about'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update error:', err);
|
||||||
|
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/about'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get about data
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutData = await getAboutData();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(aboutData, baseUrl);
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Error loading about data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
363
controllers/aboutUsController.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
const {addBaseUrlToImages} = require("../utils/imageHelper");
|
||||||
|
const About = require("../models/about");
|
||||||
|
const AboutUs = require("../models/aboutUs");
|
||||||
|
|
||||||
|
// -------------------- Public (read-only) helpers --------------------
|
||||||
|
// Map stored About document back to the original aboutUs.json shape
|
||||||
|
function transformToAboutUs(doc) {
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const hero = {
|
||||||
|
banner: doc.banner?.image || "",
|
||||||
|
title: doc.banner?.title || "",
|
||||||
|
breadcrumb: doc.banner?.text || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = Array.isArray(doc.advantages?.items)
|
||||||
|
? doc.advantages.items.map((item) => ({
|
||||||
|
number: item.number || "",
|
||||||
|
description: item.title || "",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const services = Array.isArray(doc.about?.paragraphs)
|
||||||
|
? doc.about.paragraphs.map((p) => ({title: "", description: p}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const features = Array.isArray(doc.values?.items)
|
||||||
|
? doc.values.items.map((i) => ({
|
||||||
|
title: i.title || "",
|
||||||
|
description: i.text || "",
|
||||||
|
icon: i.icon || "",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const events = Array.isArray(doc.academic_board?.members)
|
||||||
|
? doc.academic_board.members.map((m) => ({
|
||||||
|
imageUrl: m.image || "",
|
||||||
|
date: "",
|
||||||
|
title: m.title || "",
|
||||||
|
description: "",
|
||||||
|
authorName: m.name || "",
|
||||||
|
authorRole: "",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
hero,
|
||||||
|
stats,
|
||||||
|
services,
|
||||||
|
features,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get aboutUs data: prefer AboutUs collection, fallback to transforming About
|
||||||
|
const getAboutUsData = async () => {
|
||||||
|
// Prefer stored AboutUs document
|
||||||
|
const aboutUsDoc = await AboutUs.findOne().sort({updatedAt: -1});
|
||||||
|
if (aboutUsDoc)
|
||||||
|
return aboutUsDoc.toObject ? aboutUsDoc.toObject() : aboutUsDoc;
|
||||||
|
|
||||||
|
// Fallback: transform legacy About document into aboutUs shape
|
||||||
|
const about = await About.findOne().sort({updatedAt: -1});
|
||||||
|
if (!about) return null;
|
||||||
|
return transformToAboutUs(about);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Admin (CRUD on AboutUs model) helpers --------------------
|
||||||
|
// Default shape for AboutUs documents (matches data/aboutUs.json)
|
||||||
|
const getDefaultAboutUsData = () => ({
|
||||||
|
hero: {title: "", backgroundImage: ""},
|
||||||
|
introduction: {
|
||||||
|
subtitle: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
mainImage: "",
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
statistics: {
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
accommodation: {
|
||||||
|
subtitle: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
subtitle: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
gallery: [],
|
||||||
|
},
|
||||||
|
newsletter: {
|
||||||
|
imagePath: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
buttonText: "",
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
title: "",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get latest stored AboutUs document or default (returned as plain object)
|
||||||
|
const getStoredAboutUs = async () => {
|
||||||
|
const aboutUs = await AboutUs.findOne().sort({updatedAt: -1});
|
||||||
|
if (!aboutUs) return getDefaultAboutUsData();
|
||||||
|
return aboutUs.toObject ? aboutUs.toObject() : aboutUs;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public exports --------------------
|
||||||
|
// Public endpoint: return AboutUs JSON (previously rendered HTML)
|
||||||
|
exports.page = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutUsData = await getAboutUsData();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(aboutUsData || {}, baseUrl);
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("aboutUs.page error:", err);
|
||||||
|
return res.status(500).json({ error: "Error loading about-us data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint to return aboutUs JSON
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutUsData = await getAboutUsData();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(aboutUsData || {}, baseUrl);
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("aboutUs.api error:", err);
|
||||||
|
return res.status(500).json({error: "Error loading about-us data"});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint to return an array of AboutUs records (for frontend listing)
|
||||||
|
exports.apiList = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docs = await AboutUs.find().sort({ updatedAt: -1 }).lean();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = (docs || []).map((d) => addBaseUrlToImages(d || {}, baseUrl));
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("aboutUs.apiList error:", err);
|
||||||
|
return res.status(500).json({ error: "Error loading about-us list" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Admin exports --------------------
|
||||||
|
// Display AboutUs management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getStoredAboutUs();
|
||||||
|
const items = await AboutUs.find().sort({updatedAt: -1}).limit(10);
|
||||||
|
|
||||||
|
res.render("admin/aboutUs/index", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "About Us Management",
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
frontendUrl:
|
||||||
|
process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash("error_msg", "Error loading About Us data");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display create form
|
||||||
|
exports.createForm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = getDefaultAboutUsData();
|
||||||
|
|
||||||
|
res.render("admin/aboutUs/create", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Create About Us",
|
||||||
|
data,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash("error_msg", "Error loading create form");
|
||||||
|
res.redirect("/admin/about-us");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new AboutUs record
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutUsData = {
|
||||||
|
hero: JSON.parse(req.body.hero || "{}"),
|
||||||
|
introduction: JSON.parse(req.body.introduction || "{}"),
|
||||||
|
statistics: JSON.parse(req.body.statistics || "{}"),
|
||||||
|
accommodation: JSON.parse(req.body.accommodation || "{}"),
|
||||||
|
activities: JSON.parse(req.body.activities || "{}"),
|
||||||
|
newsletter: JSON.parse(req.body.newsletter || "{}"),
|
||||||
|
events: JSON.parse(req.body.events || "{}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newAboutUs = new AboutUs(aboutUsData);
|
||||||
|
await newAboutUs.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "About Us created successfully");
|
||||||
|
res.redirect("/admin/about-us");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Create error:", err);
|
||||||
|
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
|
||||||
|
res.redirect("/admin/about-us/create");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display edit form
|
||||||
|
exports.editForm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutUs = await AboutUs.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!aboutUs) {
|
||||||
|
req.flash("error_msg", "About Us record not found");
|
||||||
|
return res.redirect("/admin/about-us");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render("admin/aboutUs/edit", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Edit About Us",
|
||||||
|
data: aboutUs.toObject ? aboutUs.toObject() : aboutUs,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash("error_msg", "Error loading edit form");
|
||||||
|
res.redirect("/admin/about-us");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update AboutUs record
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get current data
|
||||||
|
const currentData = await getStoredAboutUs();
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
const sections = [
|
||||||
|
"hero",
|
||||||
|
"introduction",
|
||||||
|
"statistics",
|
||||||
|
"accommodation",
|
||||||
|
"activities",
|
||||||
|
"newsletter",
|
||||||
|
"events",
|
||||||
|
];
|
||||||
|
const errors = [];
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// Create updated data object
|
||||||
|
const updatedData = {
|
||||||
|
...(currentData.toObject ? currentData.toObject() : currentData),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each section
|
||||||
|
sections.forEach((section) => {
|
||||||
|
try {
|
||||||
|
if (!req.body[section]) {
|
||||||
|
console.warn(`No data for section: ${section}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSectionData = JSON.parse(req.body[section]);
|
||||||
|
const currentSectionData = currentData[section];
|
||||||
|
const sectionHasChanges =
|
||||||
|
JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||||
|
|
||||||
|
if (sectionHasChanges) {
|
||||||
|
updatedData[section] = newSectionData;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing section ${section}:`, error);
|
||||||
|
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
req.flash("error_msg", `Data processing error: ${errors[0]}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
req.flash("info_msg", "No changes were made");
|
||||||
|
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only update existing document; do not create a new one here
|
||||||
|
if (!currentData || !currentData._id) {
|
||||||
|
req.flash("error_msg", "No existing About Us record to update. Create one first.");
|
||||||
|
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await AboutUs.findByIdAndUpdate(currentData._id, updatedData, {
|
||||||
|
new: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.flash("success_msg", "About Us data updated successfully");
|
||||||
|
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error("Database error:", dbError);
|
||||||
|
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Update error:", err);
|
||||||
|
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/about-us"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete AboutUs record
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutUs = await AboutUs.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!aboutUs) {
|
||||||
|
req.flash("error_msg", "About Us record not found");
|
||||||
|
return res.redirect("/admin/about-us");
|
||||||
|
}
|
||||||
|
|
||||||
|
await AboutUs.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
req.flash("success_msg", "About Us record deleted successfully");
|
||||||
|
res.redirect("/admin/about-us");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete error:", err);
|
||||||
|
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
|
||||||
|
res.redirect("/admin/about-us");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preview AboutUs record
|
||||||
|
exports.preview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const aboutUs = await AboutUs.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!aboutUs) {
|
||||||
|
return res.status(404).json({error: "About Us record not found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedData = addBaseUrlToImages(aboutUs.toObject());
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Preview error:", err);
|
||||||
|
res.status(500).json({error: "Error loading preview data"});
|
||||||
|
}
|
||||||
|
};
|
||||||
1616
controllers/activityController.js
Normal file
549
controllers/bookingController.js
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const Booking = require("../models/booking");
|
||||||
|
|
||||||
|
// -------------------- Public helpers --------------------
|
||||||
|
const getBookingData = async () => {
|
||||||
|
const booking = await Booking.findOne().sort({ updatedAt: -1 });
|
||||||
|
return booking ? (booking.toObject ? booking.toObject() : booking) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load static booking JSON from `data/booking.json` (if present)
|
||||||
|
const loadStaticBooking = () => {
|
||||||
|
try {
|
||||||
|
const p = path.join(__dirname, '..', 'data', 'booking.json');
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
const raw = fs.readFileSync(p, 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('booking.loadStaticBooking error:', e && e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize booking shape: ensure configuration exists with discounts/vouchers
|
||||||
|
const normalizeBookingShape = (booking) => {
|
||||||
|
if (!booking || typeof booking !== 'object') return booking;
|
||||||
|
const b = JSON.parse(JSON.stringify(booking));
|
||||||
|
|
||||||
|
if (!b.configuration || typeof b.configuration !== 'object') {
|
||||||
|
b.configuration = { currency: 'USD', discounts: [], vouchers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure configuration.discounts and configuration.vouchers exist
|
||||||
|
if (!Array.isArray(b.configuration.discounts)) {
|
||||||
|
b.configuration.discounts = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(b.configuration.vouchers)) {
|
||||||
|
b.configuration.vouchers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deep merge: properties from `overrides` replace / merge into `base`.
|
||||||
|
const deepMerge = (base, overrides) => {
|
||||||
|
if (overrides === undefined) return base;
|
||||||
|
if (base === undefined || base === null) return overrides;
|
||||||
|
if (Array.isArray(overrides)) return overrides;
|
||||||
|
if (typeof overrides !== 'object' || overrides === null) return overrides;
|
||||||
|
const out = Object.assign({}, base);
|
||||||
|
Object.keys(overrides).forEach((k) => {
|
||||||
|
if (Array.isArray(overrides[k]) || typeof overrides[k] !== 'object' || overrides[k] === null) {
|
||||||
|
out[k] = overrides[k];
|
||||||
|
} else {
|
||||||
|
out[k] = deepMerge(base[k], overrides[k]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure booking data fields have the expected shapes to avoid runtime errors
|
||||||
|
const sanitizeBookingData = (raw) => {
|
||||||
|
const defaults = {
|
||||||
|
hero: { title: '', backgroundImage: '' },
|
||||||
|
searchBar: { locationLabel: '', holidaySeasonLabel: '', searchButtonText: '' },
|
||||||
|
filterPanel: {
|
||||||
|
title: '',
|
||||||
|
priceTitle: '',
|
||||||
|
priceLabel: '',
|
||||||
|
pricePlaceholder: '',
|
||||||
|
priceMin: 0,
|
||||||
|
priceMax: 0,
|
||||||
|
ageTitle: '',
|
||||||
|
ageMin: 0,
|
||||||
|
ageMax: 0,
|
||||||
|
ageSelectPlaceholder: '',
|
||||||
|
activitiesTitle: '',
|
||||||
|
ratingTitle: '',
|
||||||
|
ratingOptions: [],
|
||||||
|
resetButtonText: ''
|
||||||
|
},
|
||||||
|
programs: [],
|
||||||
|
holidays: [],
|
||||||
|
locations: [],
|
||||||
|
camps: [],
|
||||||
|
configuration: { currency: 'USD', discounts: [], vouchers: [] },
|
||||||
|
formSteps: [],
|
||||||
|
validation: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!raw || typeof raw !== 'object') return defaults;
|
||||||
|
|
||||||
|
// Use raw data first, then fill in missing fields with defaults
|
||||||
|
const safe = Object.assign({}, raw);
|
||||||
|
|
||||||
|
// Ensure nested objects/arrays have correct types (use raw data if valid, otherwise defaults)
|
||||||
|
safe.hero = (safe.hero && typeof safe.hero === 'object') ? safe.hero : defaults.hero;
|
||||||
|
safe.searchBar = (safe.searchBar && typeof safe.searchBar === 'object') ? safe.searchBar : defaults.searchBar;
|
||||||
|
safe.filterPanel = (safe.filterPanel && typeof safe.filterPanel === 'object') ? safe.filterPanel : defaults.filterPanel;
|
||||||
|
|
||||||
|
if (!Array.isArray(safe.filterPanel.ratingOptions)) safe.filterPanel.ratingOptions = defaults.filterPanel.ratingOptions;
|
||||||
|
|
||||||
|
safe.programs = Array.isArray(safe.programs) ? safe.programs : defaults.programs;
|
||||||
|
safe.holidays = Array.isArray(safe.holidays) ? safe.holidays : defaults.holidays;
|
||||||
|
safe.locations = Array.isArray(safe.locations) ? safe.locations : defaults.locations;
|
||||||
|
safe.camps = Array.isArray(safe.camps) ? safe.camps : defaults.camps;
|
||||||
|
|
||||||
|
// Ensure configuration has proper structure
|
||||||
|
if (!safe.configuration || typeof safe.configuration !== 'object') {
|
||||||
|
safe.configuration = defaults.configuration;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(safe.configuration.discounts)) {
|
||||||
|
safe.configuration.discounts = defaults.configuration.discounts;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(safe.configuration.vouchers)) {
|
||||||
|
safe.configuration.vouchers = defaults.configuration.vouchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure formSteps and validation have correct types
|
||||||
|
safe.formSteps = Array.isArray(safe.formSteps) ? safe.formSteps : defaults.formSteps;
|
||||||
|
safe.validation = (safe.validation && typeof safe.validation === 'object' && !Array.isArray(safe.validation)) ? safe.validation : defaults.validation;
|
||||||
|
|
||||||
|
return safe;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe JSON parse with better error handling - handles double-encoded JSON and JS object notation
|
||||||
|
const safeParse = (value, fieldName = 'unknown') => {
|
||||||
|
// If already an object or array, return as-is
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If string, try to parse
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
let cleaned = value.trim();
|
||||||
|
|
||||||
|
// Check if it looks like JavaScript object notation (has single quotes or unquoted keys)
|
||||||
|
if (cleaned.includes("'") || /\{\s*\w+:/.test(cleaned)) {
|
||||||
|
console.warn(`safeParse: Converting JS notation to JSON for "${fieldName}"`);
|
||||||
|
|
||||||
|
// Aggressive conversion approach
|
||||||
|
cleaned = cleaned
|
||||||
|
.replace(/'/g, '"') // Replace ALL single quotes with double quotes
|
||||||
|
.replace(/\r?\n|\r/g, ' ') // Remove all newlines
|
||||||
|
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
|
||||||
|
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas before } or ]
|
||||||
|
.replace(/([{,]\s*)(\w+):/g, '$1"$2":'); // Quote unquoted object keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing
|
||||||
|
let parsed = JSON.parse(cleaned);
|
||||||
|
|
||||||
|
// If result is still a string, try parsing again (double-encoded)
|
||||||
|
if (typeof parsed === 'string') {
|
||||||
|
console.warn(`safeParse: Double-encoded JSON detected for "${fieldName}"`);
|
||||||
|
parsed = JSON.parse(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`safeParse: Failed to parse field "${fieldName}"`, {
|
||||||
|
error: e.message,
|
||||||
|
valuePreview: value.substring(0, 200)
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(`Invalid JSON format for field: ${fieldName}. Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other types, return empty array or object
|
||||||
|
console.warn(`safeParse: Unexpected type for field "${fieldName}": ${typeof value}`);
|
||||||
|
return Array.isArray(value) ? [] : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate booking data structure
|
||||||
|
const validateBookingData = (data) => {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if (!data.hero || typeof data.hero !== 'object') {
|
||||||
|
errors.push('Hero data is required and must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.searchBar || typeof data.searchBar !== 'object') {
|
||||||
|
errors.push('SearchBar data is required and must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.filterPanel || typeof data.filterPanel !== 'object') {
|
||||||
|
errors.push('FilterPanel data is required and must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate arrays
|
||||||
|
if (data.programs && !Array.isArray(data.programs)) {
|
||||||
|
errors.push('Programs must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.holidays && !Array.isArray(data.holidays)) {
|
||||||
|
errors.push('Holidays must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.locations && !Array.isArray(data.locations)) {
|
||||||
|
errors.push('Locations must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.camps && !Array.isArray(data.camps)) {
|
||||||
|
errors.push('Camps must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate configuration structure
|
||||||
|
if (data.configuration) {
|
||||||
|
if (typeof data.configuration !== 'object') {
|
||||||
|
errors.push('Configuration must be an object');
|
||||||
|
} else {
|
||||||
|
if (data.configuration.discounts && !Array.isArray(data.configuration.discounts)) {
|
||||||
|
errors.push('Configuration.discounts must be an array');
|
||||||
|
}
|
||||||
|
if (data.configuration.vouchers && !Array.isArray(data.configuration.vouchers)) {
|
||||||
|
errors.push('Configuration.vouchers must be an array');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate formSteps and validation structure if provided
|
||||||
|
if (data.formSteps && !Array.isArray(data.formSteps)) {
|
||||||
|
errors.push('formSteps must be an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.validation && (typeof data.validation !== 'object' || Array.isArray(data.validation))) {
|
||||||
|
errors.push('validation must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public endpoints --------------------
|
||||||
|
// Public endpoint: return Booking JSON
|
||||||
|
exports.page = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dbBooking = await getBookingData();
|
||||||
|
const staticBooking = loadStaticBooking();
|
||||||
|
|
||||||
|
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||||
|
const normStatic = normalizeBookingShape(staticBooking);
|
||||||
|
const normDb = normalizeBookingShape(dbBooking);
|
||||||
|
|
||||||
|
// Build final payload according to BOOKING_MODE env var
|
||||||
|
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||||
|
|
||||||
|
if (!finalBooking) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "No booking data found",
|
||||||
|
message: "Please configure booking data in admin panel"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||||
|
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("booking.page error:", err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Error loading booking data",
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// API endpoint to return booking JSON
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dbBooking = await getBookingData();
|
||||||
|
const staticBooking = loadStaticBooking();
|
||||||
|
|
||||||
|
// Normalize shapes so `configuration.discounts`/`configuration.vouchers` exist
|
||||||
|
const normStatic = normalizeBookingShape(staticBooking);
|
||||||
|
const normDb = normalizeBookingShape(dbBooking);
|
||||||
|
|
||||||
|
const finalBooking = getFinalBooking(normStatic, normDb);
|
||||||
|
|
||||||
|
if (!finalBooking) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "No booking data found",
|
||||||
|
message: "Please configure booking data in admin panel"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(finalBooking, baseUrl);
|
||||||
|
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("booking.api error:", err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Error loading booking data",
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Admin endpoints --------------------
|
||||||
|
// Display Booking management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const dbBooking = await getBookingData();
|
||||||
|
const staticBooking = loadStaticBooking();
|
||||||
|
|
||||||
|
// Merge static booking with DB data (use same merge logic as public endpoints)
|
||||||
|
const normStatic = normalizeBookingShape(staticBooking);
|
||||||
|
const normDb = normalizeBookingShape(dbBooking);
|
||||||
|
const mergedData = getFinalBooking(normStatic, normDb);
|
||||||
|
|
||||||
|
// Normalize again after merge to ensure discounts/vouchers are synced to top-level
|
||||||
|
const data = normalizeBookingShape(mergedData);
|
||||||
|
|
||||||
|
// Sanitize data to ensure nested fields are objects/arrays (fixes malformed DB entries)
|
||||||
|
const safeData = sanitizeBookingData(data);
|
||||||
|
|
||||||
|
res.render("admin/booking/index", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Booking Management",
|
||||||
|
data: safeData,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("booking.index error:", err);
|
||||||
|
req.flash("error_msg", "Error loading booking page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update booking data
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
// ADD THIS DEBUG LOG
|
||||||
|
console.log('=== RAW REQUEST BODY ===');
|
||||||
|
console.log('Discounts type:', typeof req.body.discounts);
|
||||||
|
console.log('Discounts first 500 chars:', req.body.discounts?.substring(0, 500));
|
||||||
|
console.log('Vouchers type:', typeof req.body.vouchers);
|
||||||
|
console.log('Vouchers first 500 chars:', req.body.vouchers?.substring(0, 500));
|
||||||
|
console.log('========================');
|
||||||
|
const {
|
||||||
|
hero,
|
||||||
|
searchBar,
|
||||||
|
filterPanel,
|
||||||
|
programs,
|
||||||
|
holidays,
|
||||||
|
locations,
|
||||||
|
camps,
|
||||||
|
discounts,
|
||||||
|
vouchers,
|
||||||
|
formSteps,
|
||||||
|
validation: validationRaw
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings
|
||||||
|
const errors = [];
|
||||||
|
let updateData = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Raw discounts from req.body:', typeof discounts, discounts);
|
||||||
|
console.log('Raw vouchers from req.body:', typeof vouchers, vouchers);
|
||||||
|
|
||||||
|
const parsedDiscounts = safeParse(discounts, 'discounts');
|
||||||
|
const parsedVouchers = safeParse(vouchers, 'vouchers');
|
||||||
|
|
||||||
|
console.log('Parsed discounts:', typeof parsedDiscounts, Array.isArray(parsedDiscounts), parsedDiscounts);
|
||||||
|
console.log('Parsed vouchers:', typeof parsedVouchers, Array.isArray(parsedVouchers), parsedVouchers);
|
||||||
|
|
||||||
|
updateData = {
|
||||||
|
hero: safeParse(hero, 'hero'),
|
||||||
|
searchBar: safeParse(searchBar, 'searchBar'),
|
||||||
|
filterPanel: safeParse(filterPanel, 'filterPanel'),
|
||||||
|
programs: safeParse(programs, 'programs'),
|
||||||
|
holidays: safeParse(holidays, 'holidays'),
|
||||||
|
locations: safeParse(locations, 'locations'),
|
||||||
|
camps: safeParse(camps, 'camps'),
|
||||||
|
formSteps: safeParse(formSteps, 'formSteps'),
|
||||||
|
validation: safeParse(validationRaw, 'validation'),
|
||||||
|
configuration: {
|
||||||
|
currency: 'USD',
|
||||||
|
discounts: parsedDiscounts,
|
||||||
|
vouchers: parsedVouchers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('booking.update: Parse error', parseError);
|
||||||
|
req.flash("error_msg", `Data processing error: ${parseError.message}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/booking"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate data structure
|
||||||
|
const validation = validateBookingData(updateData);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
console.error('booking.update: Validation failed', validation.errors);
|
||||||
|
req.flash("error_msg", `Validation failed: ${validation.errors[0]}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/booking"));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Final updateData keys:', Object.keys(updateData));
|
||||||
|
console.log('updateData.discounts:', updateData.discounts);
|
||||||
|
console.log('updateData.configuration:', updateData.configuration);
|
||||||
|
|
||||||
|
// CRITICAL: Remove any top-level discounts/vouchers to prevent schema conflicts
|
||||||
|
// These should ONLY exist in configuration object
|
||||||
|
delete updateData.discounts;
|
||||||
|
delete updateData.vouchers;
|
||||||
|
|
||||||
|
// Update or create booking document
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
if (id && id !== 'undefined') {
|
||||||
|
result = await Booking.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...updateData,
|
||||||
|
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||||
|
},
|
||||||
|
{
|
||||||
|
new: true,
|
||||||
|
runValidators: false, // TẮT validator để tránh lỗi cast
|
||||||
|
strict: false // TẮT strict mode
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
req.flash("error_msg", "Booking document not found");
|
||||||
|
return req.session.save(() => res.redirect("/admin/booking"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Upsert: update existing or create new
|
||||||
|
result = await Booking.findOneAndUpdate(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...updateData,
|
||||||
|
$unset: { discounts: "", vouchers: "" } // Remove old top-level fields
|
||||||
|
},
|
||||||
|
{
|
||||||
|
upsert: true,
|
||||||
|
new: true,
|
||||||
|
runValidators: false, // TẮT validator
|
||||||
|
strict: false // TẮT strict mode
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash("success_msg", "Booking data updated successfully");
|
||||||
|
return req.session.save(() => res.redirect("/admin/booking"));
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error("booking.update: Database error", dbError);
|
||||||
|
req.flash("error_msg", `Database error: ${dbError.message || "Unknown"}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/booking"));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("booking.update error:", err);
|
||||||
|
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||||
|
return req.session.save(() => res.redirect("/admin/booking"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Booking selection mode: 'merge' (default) = static base, DB overrides;
|
||||||
|
// 'static' = use `data/booking.json` only; 'db' = use DB only.
|
||||||
|
const getFinalBooking = (staticBooking, dbBooking) => {
|
||||||
|
const mode = (process.env.BOOKING_MODE || 'merge').toLowerCase();
|
||||||
|
if (mode === 'static') return staticBooking || dbBooking || null;
|
||||||
|
if (mode === 'db') return dbBooking || staticBooking || null;
|
||||||
|
// default: merge static (base) with DB overrides
|
||||||
|
// If both static and db present, attempt to map DB primitive lists (e.g. ["915"]) to
|
||||||
|
// full objects from the static file (e.g. {id:"915", name:..., type:...}).
|
||||||
|
const mapDbPrimitivesToObjects = (db, stat) => {
|
||||||
|
if (!db || !stat) return db;
|
||||||
|
const dbCfg = db.configuration || {};
|
||||||
|
const statCfg = stat.configuration || {};
|
||||||
|
|
||||||
|
console.log('DB discounts/vouchers:', db.discounts, db.vouchers);
|
||||||
|
console.log('DB config:', dbCfg.discounts, dbCfg.vouchers);
|
||||||
|
console.log('Static config:', statCfg.discounts, statCfg.vouchers);
|
||||||
|
|
||||||
|
// Handle legacy: if top-level discounts/vouchers exist as strings, migrate to configuration
|
||||||
|
if (Array.isArray(db.discounts) && db.discounts.length > 0 && (!dbCfg.discounts || dbCfg.discounts.length === 0)) {
|
||||||
|
const statDiscountById = {};
|
||||||
|
if (statCfg.discounts) {
|
||||||
|
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||||
|
}
|
||||||
|
if (typeof db.discounts[0] === 'string') {
|
||||||
|
dbCfg.discounts = db.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||||
|
} else {
|
||||||
|
dbCfg.discounts = db.discounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(db.vouchers) && db.vouchers.length > 0 && (!dbCfg.vouchers || dbCfg.vouchers.length === 0)) {
|
||||||
|
const statVouchByCode = {};
|
||||||
|
if (statCfg.vouchers) {
|
||||||
|
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||||
|
}
|
||||||
|
if (typeof db.vouchers[0] === 'string') {
|
||||||
|
dbCfg.vouchers = db.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||||
|
} else {
|
||||||
|
dbCfg.vouchers = db.vouchers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If DB configuration still empty, use static data
|
||||||
|
if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length === 0 && statCfg.discounts && statCfg.discounts.length > 0) {
|
||||||
|
dbCfg.discounts = statCfg.discounts;
|
||||||
|
} else if (Array.isArray(dbCfg.discounts) && dbCfg.discounts.length > 0 && typeof dbCfg.discounts[0] === 'string') {
|
||||||
|
// Map string IDs to full objects from static
|
||||||
|
const statDiscountById = {};
|
||||||
|
if (statCfg.discounts) {
|
||||||
|
statCfg.discounts.forEach(d => { if (d && d.id) statDiscountById[String(d.id)] = d; });
|
||||||
|
}
|
||||||
|
dbCfg.discounts = dbCfg.discounts.map(s => statDiscountById[String(s)] || { id: s, name: '', type: 'percentage', value: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length === 0 && statCfg.vouchers && statCfg.vouchers.length > 0) {
|
||||||
|
dbCfg.vouchers = statCfg.vouchers;
|
||||||
|
} else if (Array.isArray(dbCfg.vouchers) && dbCfg.vouchers.length > 0 && typeof dbCfg.vouchers[0] === 'string') {
|
||||||
|
// Map string codes to full objects from static
|
||||||
|
const statVouchByCode = {};
|
||||||
|
if (statCfg.vouchers) {
|
||||||
|
statCfg.vouchers.forEach(v => { if (v && v.validCodes) statVouchByCode[String(v.validCodes)] = v; });
|
||||||
|
}
|
||||||
|
dbCfg.vouchers = dbCfg.vouchers.map(s => statVouchByCode[String(s)] || { validCodes: s, type: 'percentage', value: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign({}, db, { configuration: dbCfg });
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedDb = mapDbPrimitivesToObjects(dbBooking, staticBooking);
|
||||||
|
const merged = staticBooking ? deepMerge(staticBooking, mappedDb || {}) : (mappedDb || null);
|
||||||
|
|
||||||
|
// Clean up: remove top-level discounts/vouchers after migrating to configuration
|
||||||
|
if (merged) {
|
||||||
|
delete merged.discounts;
|
||||||
|
delete merged.vouchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
558
controllers/bookingSubmissionController.js
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
const BookingSubmission = require('../models/bookingSubmission');
|
||||||
|
const Activity = require('../models/activity');
|
||||||
|
|
||||||
|
// API endpoint để tạo booking submission mới
|
||||||
|
exports.submitBooking = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
activityId,
|
||||||
|
sessionId,
|
||||||
|
parentFirstName,
|
||||||
|
parentLastName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
address,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
postalCode,
|
||||||
|
participantFirstName,
|
||||||
|
participantLastName,
|
||||||
|
participantBirthDate,
|
||||||
|
participantGender,
|
||||||
|
numberOfParticipants,
|
||||||
|
medicalConditions,
|
||||||
|
dietaryRestrictions,
|
||||||
|
specialRequests,
|
||||||
|
emergencyContact,
|
||||||
|
emergencyPhone,
|
||||||
|
agreeTerms,
|
||||||
|
agreeNewsletter
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!activityId || !sessionId || !parentFirstName || !parentLastName ||
|
||||||
|
!email || !phone || !address || !city || !country || !postalCode ||
|
||||||
|
!participantFirstName || !participantLastName || !participantBirthDate ||
|
||||||
|
!participantGender || !emergencyContact || !emergencyPhone || !agreeTerms) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields',
|
||||||
|
message: 'Please fill in all required fields'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify activity exists
|
||||||
|
const activity = await Activity.findById(activityId);
|
||||||
|
if (!activity) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Activity not found',
|
||||||
|
message: 'The selected activity does not exist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session exists and is active
|
||||||
|
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Session not found',
|
||||||
|
message: 'The selected session does not exist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.isActive) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Session not available',
|
||||||
|
message: 'The selected session is no longer available for booking'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check availability based on participant gender
|
||||||
|
const currentBookings = await BookingSubmission.countDocuments({
|
||||||
|
activityId,
|
||||||
|
sessionId,
|
||||||
|
participantGender,
|
||||||
|
status: { $in: ['pending', 'confirmed'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSpots = participantGender === 'male'
|
||||||
|
? session.totalMaleSpots - session.bookedMaleSpots
|
||||||
|
: session.totalFemaleSpots - session.bookedFemaleSpots;
|
||||||
|
|
||||||
|
if (currentBookings >= availableSpots) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Session full',
|
||||||
|
message: `No more spots available for ${participantGender} participants in this session`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total amount based on activity price and number of participants
|
||||||
|
const totalAmount = (activity.price || 0) * (parseInt(numberOfParticipants) || 1);
|
||||||
|
|
||||||
|
// Create booking submission
|
||||||
|
const bookingSubmission = new BookingSubmission({
|
||||||
|
activityId,
|
||||||
|
sessionId,
|
||||||
|
parentFirstName: parentFirstName.trim(),
|
||||||
|
parentLastName: parentLastName.trim(),
|
||||||
|
email: email.toLowerCase().trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
address: address.trim(),
|
||||||
|
city: city.trim(),
|
||||||
|
country: country.trim(),
|
||||||
|
postalCode: postalCode.trim(),
|
||||||
|
participantFirstName: participantFirstName.trim(),
|
||||||
|
participantLastName: participantLastName.trim(),
|
||||||
|
participantBirthDate: new Date(participantBirthDate),
|
||||||
|
participantGender,
|
||||||
|
numberOfParticipants: parseInt(numberOfParticipants) || 1,
|
||||||
|
medicalConditions: (medicalConditions || '').trim(),
|
||||||
|
dietaryRestrictions: dietaryRestrictions || 'none',
|
||||||
|
specialRequests: (specialRequests || '').trim(),
|
||||||
|
emergencyContact: emergencyContact.trim(),
|
||||||
|
emergencyPhone: emergencyPhone.trim(),
|
||||||
|
agreeTerms: Boolean(agreeTerms),
|
||||||
|
agreeNewsletter: Boolean(agreeNewsletter),
|
||||||
|
totalAmount,
|
||||||
|
status: 'pending',
|
||||||
|
paymentStatus: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
await bookingSubmission.save();
|
||||||
|
|
||||||
|
// Update session booked spots
|
||||||
|
const updateField = participantGender === 'male' ? 'bookingSessions.$.bookedMaleSpots' : 'bookingSessions.$.bookedFemaleSpots';
|
||||||
|
await Activity.updateOne(
|
||||||
|
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||||
|
{ $inc: { [updateField]: 1 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Populate activity info for response
|
||||||
|
await bookingSubmission.populate('activityId', 'name price');
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Booking submitted successfully',
|
||||||
|
booking: {
|
||||||
|
id: bookingSubmission._id,
|
||||||
|
activityName: bookingSubmission.activityId.name,
|
||||||
|
sessionId: bookingSubmission.sessionId,
|
||||||
|
participantName: `${bookingSubmission.participantFirstName} ${bookingSubmission.participantLastName}`,
|
||||||
|
totalAmount: bookingSubmission.totalAmount,
|
||||||
|
status: bookingSubmission.status
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('submitBooking error:', error);
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (error.name === 'ValidationError') {
|
||||||
|
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
message: validationErrors.join(', ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Server error',
|
||||||
|
message: 'An error occurred while processing your booking. Please try again.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint để lấy thông tin session availability
|
||||||
|
exports.getSessionAvailability = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { activityId, sessionId } = req.params;
|
||||||
|
|
||||||
|
const activity = await Activity.findById(activityId);
|
||||||
|
if (!activity) {
|
||||||
|
return res.status(404).json({ error: 'Activity not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = activity.bookingSessions?.find(s => s.sessionId === sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current booking counts
|
||||||
|
const maleBookings = await BookingSubmission.countDocuments({
|
||||||
|
activityId,
|
||||||
|
sessionId,
|
||||||
|
participantGender: 'male',
|
||||||
|
status: { $in: ['pending', 'confirmed'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const femaleBookings = await BookingSubmission.countDocuments({
|
||||||
|
activityId,
|
||||||
|
sessionId,
|
||||||
|
participantGender: 'female',
|
||||||
|
status: { $in: ['pending', 'confirmed'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
sessionId,
|
||||||
|
isActive: session.isActive,
|
||||||
|
startDate: session.startDate,
|
||||||
|
endDate: session.endDate,
|
||||||
|
overnightStays: session.overnightStays,
|
||||||
|
price: session.price || activity.price,
|
||||||
|
availability: {
|
||||||
|
male: {
|
||||||
|
total: session.totalMaleSpots,
|
||||||
|
booked: maleBookings,
|
||||||
|
available: Math.max(0, session.totalMaleSpots - maleBookings)
|
||||||
|
},
|
||||||
|
female: {
|
||||||
|
total: session.totalFemaleSpots,
|
||||||
|
booked: femaleBookings,
|
||||||
|
available: Math.max(0, session.totalFemaleSpots - femaleBookings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getSessionAvailability error:', error);
|
||||||
|
return res.status(500).json({ error: 'Error loading session availability' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint để lấy tất cả sessions có sẵn cho một activity
|
||||||
|
exports.getAvailableSessions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { activityId } = req.params;
|
||||||
|
|
||||||
|
const activity = await Activity.findById(activityId);
|
||||||
|
if (!activity) {
|
||||||
|
return res.status(404).json({ error: 'Activity not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = activity.bookingSessions || [];
|
||||||
|
const availableSessions = [];
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session.isActive) continue;
|
||||||
|
|
||||||
|
// Get current booking counts
|
||||||
|
const maleBookings = await BookingSubmission.countDocuments({
|
||||||
|
activityId,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
participantGender: 'male',
|
||||||
|
status: { $in: ['pending', 'confirmed'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const femaleBookings = await BookingSubmission.countDocuments({
|
||||||
|
activityId,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
participantGender: 'female',
|
||||||
|
status: { $in: ['pending', 'confirmed'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const maleAvailable = Math.max(0, session.totalMaleSpots - maleBookings);
|
||||||
|
const femaleAvailable = Math.max(0, session.totalFemaleSpots - femaleBookings);
|
||||||
|
|
||||||
|
// Only include sessions that have available spots
|
||||||
|
if (maleAvailable > 0 || femaleAvailable > 0) {
|
||||||
|
availableSessions.push({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
startDate: session.startDate,
|
||||||
|
endDate: session.endDate,
|
||||||
|
overnightStays: session.overnightStays,
|
||||||
|
price: session.price || activity.price,
|
||||||
|
availability: {
|
||||||
|
male: {
|
||||||
|
total: session.totalMaleSpots,
|
||||||
|
booked: maleBookings,
|
||||||
|
available: maleAvailable
|
||||||
|
},
|
||||||
|
female: {
|
||||||
|
total: session.totalFemaleSpots,
|
||||||
|
booked: femaleBookings,
|
||||||
|
available: femaleAvailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
activityId,
|
||||||
|
activityName: activity.name,
|
||||||
|
sessions: availableSessions
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getAvailableSessions error:', error);
|
||||||
|
return res.status(500).json({ error: 'Error loading available sessions' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint để cập nhật booking submission
|
||||||
|
exports.updateBookingSubmission = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { bookingId } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
|
||||||
|
// Find the booking
|
||||||
|
let booking = await BookingSubmission.findById(bookingId);
|
||||||
|
|
||||||
|
// If not found as a separate document, try to find it as an embedded booking in Activity.bookingSessions
|
||||||
|
let activityContaining = null;
|
||||||
|
let sessionIndex = -1;
|
||||||
|
let bookingIndex = -1;
|
||||||
|
if (!booking) {
|
||||||
|
activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||||
|
if (!activityContaining) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Booking not found',
|
||||||
|
message: 'The booking submission does not exist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// locate the exact session and booking positions
|
||||||
|
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||||
|
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||||
|
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||||
|
if (bi !== -1) {
|
||||||
|
sessionIndex = si;
|
||||||
|
bookingIndex = bi;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||||
|
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||||
|
}
|
||||||
|
|
||||||
|
booking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define allowed fields to update
|
||||||
|
const allowedUpdates = [
|
||||||
|
'status',
|
||||||
|
'paymentStatus',
|
||||||
|
'paidAmount',
|
||||||
|
'totalAmount',
|
||||||
|
'adminNotes',
|
||||||
|
'emergencyContact',
|
||||||
|
'emergencyPhone',
|
||||||
|
'medicalConditions',
|
||||||
|
'dietaryRestrictions',
|
||||||
|
'specialRequests'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build update object with only allowed fields
|
||||||
|
const updateFields = {};
|
||||||
|
for (const field of allowedUpdates) {
|
||||||
|
if (updateData[field] !== undefined) {
|
||||||
|
updateFields[field] = updateData[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateFields).length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'No valid fields to update',
|
||||||
|
message: 'Please provide at least one valid field to update'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If booking is a separate document, update the BookingSubmission collection
|
||||||
|
if (activityContaining === null) {
|
||||||
|
const updatedBooking = await BookingSubmission.findByIdAndUpdate(
|
||||||
|
bookingId,
|
||||||
|
updateFields,
|
||||||
|
{ new: true, runValidators: true }
|
||||||
|
).populate('activityId', 'name price');
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Booking updated successfully',
|
||||||
|
booking: updatedBooking
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise update the embedded booking in the Activity document
|
||||||
|
const currentBooking = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||||
|
|
||||||
|
// Handle status updates and spot adjustments
|
||||||
|
const newStatus = updateData.status || updateData.bookingStatus;
|
||||||
|
const currentStatus = currentBooking.status || currentBooking.bookingStatus;
|
||||||
|
|
||||||
|
// Apply allowed updates to the embedded booking
|
||||||
|
const allowedEmbeddedUpdates = [
|
||||||
|
'status', 'bookingStatus', 'paymentStatus', 'paidAmount', 'totalAmount', 'adminNotes',
|
||||||
|
'emergencyContact', 'emergencyPhone', 'medicalConditions', 'dietaryRestrictions', 'specialRequests'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of allowedEmbeddedUpdates) {
|
||||||
|
if (updateData[field] !== undefined) {
|
||||||
|
if (field === 'status') {
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].status = updateData.status;
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex].bookingStatus = updateData.status;
|
||||||
|
} else {
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex][field] = updateData[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If status change affects spots, adjust counts
|
||||||
|
if (newStatus && newStatus !== currentStatus) {
|
||||||
|
const numberOfParticipants = currentBooking.numberOfParticipants || 1;
|
||||||
|
const participantGender = currentBooking.participantGender;
|
||||||
|
|
||||||
|
// If booking is being cancelled, free up spots
|
||||||
|
if (newStatus === 'cancelled' && currentStatus !== 'cancelled') {
|
||||||
|
if (participantGender === 'male') {
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||||
|
} else if (participantGender === 'female') {
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If restoring from cancelled, ensure capacity then book spots
|
||||||
|
if (currentStatus === 'cancelled' && newStatus !== 'cancelled') {
|
||||||
|
if (participantGender === 'male') {
|
||||||
|
const totalMale = activityContaining.bookingSessions[sessionIndex].totalMaleSpots;
|
||||||
|
const currentMale = activityContaining.bookingSessions[sessionIndex].bookedMaleSpots;
|
||||||
|
if (currentMale + numberOfParticipants > totalMale) {
|
||||||
|
return res.status(400).json({ error: "Not enough male spots available to restore this booking" });
|
||||||
|
}
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots += numberOfParticipants;
|
||||||
|
} else if (participantGender === 'female') {
|
||||||
|
const totalFemale = activityContaining.bookingSessions[sessionIndex].totalFemaleSpots;
|
||||||
|
const currentFemale = activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots;
|
||||||
|
if (currentFemale + numberOfParticipants > totalFemale) {
|
||||||
|
return res.status(400).json({ error: "Not enough female spots available to restore this booking" });
|
||||||
|
}
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots += numberOfParticipants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await activityContaining.save();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Embedded booking updated successfully',
|
||||||
|
booking: activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex]
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('updateBookingSubmission error:', error);
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (error.name === 'ValidationError') {
|
||||||
|
const validationErrors = Object.values(error.errors).map(err => err.message);
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
message: validationErrors.join(', ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Server error',
|
||||||
|
message: 'An error occurred while updating the booking'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint để xóa booking submission
|
||||||
|
exports.deleteBookingSubmission = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { bookingId } = req.params;
|
||||||
|
|
||||||
|
// Find and delete the booking
|
||||||
|
let booking = await BookingSubmission.findById(bookingId);
|
||||||
|
|
||||||
|
// If not found in separate collection, try to delete embedded booking in Activity
|
||||||
|
if (!booking) {
|
||||||
|
const activityContaining = await Activity.findOne({ 'bookingSessions.bookingList._id': bookingId });
|
||||||
|
if (!activityContaining) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Booking not found',
|
||||||
|
message: 'The booking submission does not exist'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// locate session and booking
|
||||||
|
let sessionIndex = -1;
|
||||||
|
let bookingIndex = -1;
|
||||||
|
for (let si = 0; si < activityContaining.bookingSessions.length; si++) {
|
||||||
|
const bl = activityContaining.bookingSessions[si].bookingList || [];
|
||||||
|
const bi = bl.findIndex(b => b._id && b._id.toString() === bookingId.toString());
|
||||||
|
if (bi !== -1) {
|
||||||
|
sessionIndex = si;
|
||||||
|
bookingIndex = bi;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionIndex === -1 || bookingIndex === -1) {
|
||||||
|
return res.status(404).json({ error: 'Booking not found', message: 'The booking submission does not exist' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingToDelete = activityContaining.bookingSessions[sessionIndex].bookingList[bookingIndex];
|
||||||
|
|
||||||
|
// Free up spots if booking is not cancelled
|
||||||
|
if ((bookingToDelete.bookingStatus || bookingToDelete.status) !== 'cancelled') {
|
||||||
|
const numberOfParticipants = bookingToDelete.numberOfParticipants || 1;
|
||||||
|
const participantGender = bookingToDelete.participantGender;
|
||||||
|
|
||||||
|
if (participantGender === 'male') {
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookedMaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedMaleSpots - numberOfParticipants);
|
||||||
|
} else if (participantGender === 'female') {
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots = Math.max(0, activityContaining.bookingSessions[sessionIndex].bookedFemaleSpots - numberOfParticipants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove booking and save
|
||||||
|
activityContaining.bookingSessions[sessionIndex].bookingList.splice(bookingIndex, 1);
|
||||||
|
await activityContaining.save();
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Embedded booking deleted successfully',
|
||||||
|
booking: {
|
||||||
|
id: bookingId,
|
||||||
|
participantName: `${bookingToDelete.participantFirstName} ${bookingToDelete.participantLastName}`,
|
||||||
|
email: bookingToDelete.email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store info for session spot adjustment
|
||||||
|
const { activityId, sessionId, participantGender, numberOfParticipants } = booking;
|
||||||
|
|
||||||
|
// Delete the booking
|
||||||
|
await BookingSubmission.findByIdAndDelete(bookingId);
|
||||||
|
|
||||||
|
// Update session booked spots (decrease the count)
|
||||||
|
if (booking.status !== 'cancelled') {
|
||||||
|
const updateField = participantGender === 'male'
|
||||||
|
? 'bookingSessions.$.bookedMaleSpots'
|
||||||
|
: 'bookingSessions.$.bookedFemaleSpots';
|
||||||
|
|
||||||
|
await Activity.updateOne(
|
||||||
|
{ _id: activityId, 'bookingSessions.sessionId': sessionId },
|
||||||
|
{ $inc: { [updateField]: -numberOfParticipants } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Booking deleted successfully',
|
||||||
|
booking: {
|
||||||
|
id: bookingId,
|
||||||
|
participantName: `${booking.participantFirstName} ${booking.participantLastName}`,
|
||||||
|
email: booking.email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('deleteBookingSubmission error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Server error',
|
||||||
|
message: 'An error occurred while deleting the booking'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
322
controllers/campLocationController.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||||
|
const CampLocation = require('../models/campLocation');
|
||||||
|
|
||||||
|
// -------------------- Public (read-only) helpers --------------------
|
||||||
|
// Get camp location data from MongoDB
|
||||||
|
const getCampLocationData = async () => {
|
||||||
|
const campLocation = await CampLocation.findOne().sort({ updatedAt: -1 });
|
||||||
|
if (!campLocation) return null;
|
||||||
|
return campLocation.toObject ? campLocation.toObject() : campLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Admin (CRUD on CampLocation model) helpers --------------------
|
||||||
|
// Default shape for CampLocation documents
|
||||||
|
const getDefaultCampLocationData = () => ({
|
||||||
|
metadata: { title: '', description: '', keywords: '' },
|
||||||
|
hero: { title: '', subtitle: '', backgroundImage: '' },
|
||||||
|
camps: [],
|
||||||
|
locations: [],
|
||||||
|
locationsSection: { title: '', description: '' },
|
||||||
|
intro: { title: '', description: '' },
|
||||||
|
map: {
|
||||||
|
coordinates: { lat: 0, lng: 0 },
|
||||||
|
zoom: 15,
|
||||||
|
location: '',
|
||||||
|
markerTitle: '',
|
||||||
|
tileLayer: {
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '',
|
||||||
|
maxZoom: 18,
|
||||||
|
minZoom: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
faq: [],
|
||||||
|
faqSection: { title: '', description: '' },
|
||||||
|
welcomeQuote: { text: '', author: '' },
|
||||||
|
securityConcept: { title: '', description: '', items: [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get latest stored CampLocation document or default
|
||||||
|
const getStoredCampLocation = async () => {
|
||||||
|
const campLocation = await CampLocation.findOne().sort({ updatedAt: -1 });
|
||||||
|
if (!campLocation) return getDefaultCampLocationData();
|
||||||
|
return campLocation.toObject ? campLocation.toObject() : campLocation;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public exports --------------------
|
||||||
|
// API endpoint to return camp location JSON
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const campLocationData = await getCampLocationData();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(campLocationData || {}, baseUrl);
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('campLocation.api error:', err);
|
||||||
|
return res.status(500).json({ error: 'Error loading camp location data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint to return an array of CampLocation records (for frontend listing)
|
||||||
|
exports.apiList = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const docs = await CampLocation.find().sort({ updatedAt: -1 }).lean();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = (docs || []).map((d) => addBaseUrlToImages(d || {}, baseUrl));
|
||||||
|
return res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('campLocation.apiList error:', err);
|
||||||
|
return res.status(500).json({ error: 'Error loading camp location list' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Admin exports --------------------
|
||||||
|
// Display camp location management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Set cache control headers to prevent caching
|
||||||
|
res.set({
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await getStoredCampLocation();
|
||||||
|
const items = await CampLocation.find().sort({ updatedAt: -1 }).limit(10);
|
||||||
|
|
||||||
|
res.render('admin/campLocation/index', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Camp Location Management',
|
||||||
|
data,
|
||||||
|
items,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || `${req.protocol}://${req.get('host')}`,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading camp location data');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display create form
|
||||||
|
exports.createForm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = getDefaultCampLocationData();
|
||||||
|
|
||||||
|
res.render('admin/campLocation/create', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Create Camp Location',
|
||||||
|
data,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading create form');
|
||||||
|
res.redirect('/admin/camp-location');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create new CampLocation record
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const campLocationData = {
|
||||||
|
metadata: JSON.parse(req.body.metadata || '{}'),
|
||||||
|
hero: JSON.parse(req.body.hero || '{}'),
|
||||||
|
camps: JSON.parse(req.body.camps || '[]'),
|
||||||
|
locations: JSON.parse(req.body.locations || '[]'),
|
||||||
|
locationsSection: JSON.parse(req.body.locationsSection || '{}'),
|
||||||
|
intro: JSON.parse(req.body.intro || '{}'),
|
||||||
|
map: JSON.parse(req.body.map || '{}'),
|
||||||
|
faq: JSON.parse(req.body.faq || '[]'),
|
||||||
|
faqSection: JSON.parse(req.body.faqSection || '{}'),
|
||||||
|
welcomeQuote: JSON.parse(req.body.welcomeQuote || '{}'),
|
||||||
|
securityConcept: JSON.parse(req.body.securityConcept || '{}')
|
||||||
|
};
|
||||||
|
|
||||||
|
const newCampLocation = new CampLocation(campLocationData);
|
||||||
|
await newCampLocation.save();
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Camp Location created successfully');
|
||||||
|
res.redirect('/admin/camp-location');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create error:', err);
|
||||||
|
req.flash('error_msg', `Create error: ${err.message || 'Unknown'}`);
|
||||||
|
res.redirect('/admin/camp-location/create');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display edit form
|
||||||
|
exports.editForm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const campLocation = await CampLocation.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!campLocation) {
|
||||||
|
req.flash('error_msg', 'Camp Location record not found');
|
||||||
|
return res.redirect('/admin/camp-location');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('admin/campLocation/edit', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Edit Camp Location',
|
||||||
|
data: campLocation.toObject ? campLocation.toObject() : campLocation,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading edit form');
|
||||||
|
res.redirect('/admin/camp-location');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update CampLocation record
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get current data
|
||||||
|
const currentData = await getStoredCampLocation();
|
||||||
|
|
||||||
|
// List of sections to update
|
||||||
|
const sections = [
|
||||||
|
'metadata',
|
||||||
|
'hero',
|
||||||
|
'camps',
|
||||||
|
'locations',
|
||||||
|
'locationsSection',
|
||||||
|
'intro',
|
||||||
|
'map',
|
||||||
|
'faq',
|
||||||
|
'faqSection',
|
||||||
|
'welcomeQuote',
|
||||||
|
'securityConcept'
|
||||||
|
];
|
||||||
|
const errors = [];
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// Create updated data object
|
||||||
|
const updatedData = {
|
||||||
|
...(currentData.toObject ? currentData.toObject() : currentData)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each section
|
||||||
|
sections.forEach((section) => {
|
||||||
|
try {
|
||||||
|
if (!req.body[section]) {
|
||||||
|
console.warn(`No data for section: ${section}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSectionData = JSON.parse(req.body[section]);
|
||||||
|
const currentSectionData = currentData[section];
|
||||||
|
const sectionHasChanges =
|
||||||
|
JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||||
|
|
||||||
|
if (sectionHasChanges) {
|
||||||
|
updatedData[section] = newSectionData;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing section ${section}:`, error);
|
||||||
|
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
req.flash('error_msg', `Data processing error: ${errors[0]}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
req.flash('info_msg', 'No changes were made');
|
||||||
|
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Only update existing document; do not create a new one here
|
||||||
|
if (!currentData || !currentData._id) {
|
||||||
|
req.flash('error_msg', 'No existing Camp Location record to update. Create one first.');
|
||||||
|
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document and ensure it's saved to MongoDB
|
||||||
|
const updatedDoc = await CampLocation.findByIdAndUpdate(
|
||||||
|
currentData._id,
|
||||||
|
updatedData,
|
||||||
|
{
|
||||||
|
new: true,
|
||||||
|
runValidators: true,
|
||||||
|
useFindAndModify: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the update was successful
|
||||||
|
if (!updatedDoc) {
|
||||||
|
throw new Error('Failed to update document');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force a save to ensure MongoDB commits the changes
|
||||||
|
await updatedDoc.save();
|
||||||
|
|
||||||
|
console.log('✓ Camp location updated successfully in MongoDB');
|
||||||
|
console.log('✓ Document ID:', updatedDoc._id);
|
||||||
|
console.log('✓ Updated at:', updatedDoc.updatedAt);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Camp location data updated successfully');
|
||||||
|
// Redirect back to the same page with cache-busting parameter to force refresh
|
||||||
|
const timestamp = Date.now();
|
||||||
|
return req.session.save(() => res.redirect(`/admin/camp-location?updated=${timestamp}`));
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Database error:', dbError);
|
||||||
|
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update error:', err);
|
||||||
|
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/camp-location'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete CampLocation record
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const campLocation = await CampLocation.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!campLocation) {
|
||||||
|
req.flash('error_msg', 'Camp Location record not found');
|
||||||
|
return res.redirect('/admin/camp-location');
|
||||||
|
}
|
||||||
|
|
||||||
|
await CampLocation.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Camp Location record deleted successfully');
|
||||||
|
res.redirect('/admin/camp-location');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete error:', err);
|
||||||
|
req.flash('error_msg', `Delete error: ${err.message || 'Unknown'}`);
|
||||||
|
res.redirect('/admin/camp-location');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preview CampLocation record
|
||||||
|
exports.preview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const campLocation = await CampLocation.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!campLocation) {
|
||||||
|
return res.status(404).json({ error: 'Camp Location record not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(
|
||||||
|
campLocation.toObject ? campLocation.toObject() : campLocation,
|
||||||
|
baseUrl
|
||||||
|
);
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading preview data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
181
controllers/contactController.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const Contact = require("../models/contact");
|
||||||
|
|
||||||
|
// Get contact data from MongoDB
|
||||||
|
const getContactData = async () => {
|
||||||
|
const contact = await Contact.findOne({ name: "default" });
|
||||||
|
if (!contact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contact.toObject();
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get contact data
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contact = await getContactData();
|
||||||
|
if (!contact) {
|
||||||
|
return res.status(404).json({ error: "Contact data not found" });
|
||||||
|
}
|
||||||
|
const baseUrl =
|
||||||
|
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||||
|
const processedData = addBaseUrlToImages(contact, baseUrl);
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("API Error:", err);
|
||||||
|
res.status(500).json({ error: "Error loading contact data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy toàn bộ contact data
|
||||||
|
exports.getContactData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contactData = await getContactData();
|
||||||
|
if (!contactData) {
|
||||||
|
return res.status(404).json({ error: "Contact data not found" });
|
||||||
|
}
|
||||||
|
res.json(contactData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting contact data:", error);
|
||||||
|
res.status(500).json({ error: "Error loading contact data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render admin view
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = (await getContactData()) || {
|
||||||
|
hero: {
|
||||||
|
title: "Contact Us",
|
||||||
|
backgroundImage: "",
|
||||||
|
overlayColor: "rgba(0, 0, 0, 0)",
|
||||||
|
sectionClass: "",
|
||||||
|
titleClass: "",
|
||||||
|
enableScrollspy: false,
|
||||||
|
backgroundPosition: "center",
|
||||||
|
},
|
||||||
|
contactCards: [],
|
||||||
|
map: {
|
||||||
|
coordinates: { lat: 0, lng: 0 },
|
||||||
|
zoom: 15,
|
||||||
|
location: "",
|
||||||
|
markerTitle: "",
|
||||||
|
tileLayer: {
|
||||||
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
attribution: "",
|
||||||
|
maxZoom: 18,
|
||||||
|
minZoom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
sectionLabel: "",
|
||||||
|
heading: "",
|
||||||
|
fields: [],
|
||||||
|
submitButton: { text: "Send Message" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
res.render("admin/contact/index", {
|
||||||
|
title: "Contact Management",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in contact index:", error);
|
||||||
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật dữ liệu contact
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, contactCards, map, form } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings nếu cần
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroData = parseJson(hero);
|
||||||
|
const contactCardsData = parseJson(contactCards);
|
||||||
|
const mapData = parseJson(map);
|
||||||
|
const formData = parseJson(form);
|
||||||
|
|
||||||
|
// Tìm hoặc tạo contact
|
||||||
|
let contact = await Contact.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
// Tạo mới với default values
|
||||||
|
contact = new Contact({
|
||||||
|
name: "default",
|
||||||
|
hero: heroData || {
|
||||||
|
title: "Contact Us",
|
||||||
|
backgroundImage: "",
|
||||||
|
overlayColor: "rgba(0, 0, 0, 0)",
|
||||||
|
sectionClass: "",
|
||||||
|
titleClass: "",
|
||||||
|
enableScrollspy: false,
|
||||||
|
backgroundPosition: "center",
|
||||||
|
},
|
||||||
|
contactCards: (contactCardsData || []).map((card) => ({
|
||||||
|
...card,
|
||||||
|
iconType: card.iconType || "",
|
||||||
|
iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'),
|
||||||
|
})),
|
||||||
|
map: mapData || {
|
||||||
|
coordinates: { lat: 0, lng: 0 },
|
||||||
|
zoom: 15,
|
||||||
|
location: "",
|
||||||
|
markerTitle: "",
|
||||||
|
tileLayer: {
|
||||||
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
attribution: "",
|
||||||
|
maxZoom: 18,
|
||||||
|
minZoom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
form: formData || {
|
||||||
|
sectionLabel: "",
|
||||||
|
heading: "",
|
||||||
|
fields: [],
|
||||||
|
submitButton: { text: "Send Message" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Cập nhật dữ liệu
|
||||||
|
if (heroData) contact.hero = heroData;
|
||||||
|
if (contactCardsData && Array.isArray(contactCardsData)) {
|
||||||
|
// Đảm bảo mỗi card có iconType và iconSource
|
||||||
|
contact.contactCards = contactCardsData.map((card) => ({
|
||||||
|
...card,
|
||||||
|
iconType: card.iconType || "",
|
||||||
|
iconSource: card.iconSource || (card.iconType && card.iconType.startsWith('/uploads/') ? 'image' : 'fontawesome'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (mapData) contact.map = mapData;
|
||||||
|
if (formData) contact.form = formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
await contact.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "Contact updated successfully");
|
||||||
|
res.redirect("/admin/contact");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating contact:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating contact");
|
||||||
|
res.redirect("/admin/contact");
|
||||||
|
}
|
||||||
|
};
|
||||||
17
controllers/dashboardController.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { readJsonFile } = require('../utils/jsonHelper');
|
||||||
|
|
||||||
|
// Hiển thị dashboard
|
||||||
|
exports.getDashboard = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.render('admin/dashboard', {
|
||||||
|
title: 'Dashboard',
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.render('admin/dashboard', {
|
||||||
|
title: 'Dashboard',
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
720
controllers/faqController.js
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
// controllers/faqController.js
|
||||||
|
const FAQ = require("../models/faq");
|
||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
|
||||||
|
// Helper function để lấy FAQ data
|
||||||
|
const getFAQData = async () => {
|
||||||
|
try {
|
||||||
|
const faq = await FAQ.findOne({ name: "default" });
|
||||||
|
return faq ? faq.toObject() : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting FAQ data:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function để tạo FAQ data mặc định hoàn chỉnh
|
||||||
|
const getDefaultFAQData = () => {
|
||||||
|
return {
|
||||||
|
name: "default",
|
||||||
|
hero: {
|
||||||
|
title: "Go and Grow Camp",
|
||||||
|
backgroundImage: "yootheme/cache/18/faqs_header_new.jpg",
|
||||||
|
overlayColor: "rgba(0, 0, 0, 0)",
|
||||||
|
sectionClass: "uk-section-secondary uk-section-overlap uk-preserve-color uk-light",
|
||||||
|
titleClass: "uk-heading-large uk-text-center !text-[5vw]",
|
||||||
|
enableScrollspy: true,
|
||||||
|
backgroundPosition: "top-center"
|
||||||
|
},
|
||||||
|
sidebarNav: [
|
||||||
|
{
|
||||||
|
id: "general-information",
|
||||||
|
label: "General Information"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camps",
|
||||||
|
label: "Camps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camp-routine",
|
||||||
|
label: "Camp Routine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camp-counselors",
|
||||||
|
label: "Camp Counselors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camp-rules",
|
||||||
|
label: "Camp Rules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "safety",
|
||||||
|
label: "Safety"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accommodation-catering",
|
||||||
|
label: "Accommodation & Catering"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "transfers-shuttles",
|
||||||
|
label: "Transfers & Shuttles"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
contactBox: {
|
||||||
|
title: "Let's plan your perfect nature escape",
|
||||||
|
phone: {
|
||||||
|
icon: "phone",
|
||||||
|
text: "+(123)-456-789"
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
icon: "email",
|
||||||
|
text: "hello@ggcamp.org"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
faqSections: [
|
||||||
|
{
|
||||||
|
id: "general-information",
|
||||||
|
title: "General Information",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "What are FAQ?",
|
||||||
|
description: "FAQ are the initials for \"Frequently Asked Questions\".\n\nThe FAQ have been compiled by us over a long period of time and are intended to help give a general overview of our camps and clarify questions that arise before booking a camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "General booking process",
|
||||||
|
description: "Once the booking has been confirmed by us, you will receive an e-mail requesting a deposit. As soon as we have received this, you will receive an e-mail with a payment confirmation.\nPlease have a look at the welcome package, which will reach you by e-mail with the Last Travel Information. This contains information that applies to the camp you have booked.\n\nStep 1: Registration\nStep 2: Receipt of registration confirmation, total invoice and deposit request (e-mail)\nStep 3: Deposit of USD 50 (due within 7 days after booking)\nStep 4: Receiving an email with the latest important travel information, a packing list, addresses and important emergency phone numbers plus remaining payment request about 3-4 weeks before the camp starts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Terms & Conditions",
|
||||||
|
description: "Our Terms & Conditions can be found in our official documents section."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Where can I find a packing guide for Camps?",
|
||||||
|
description: "Just click here to download our packing list."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Where can I find contact information from Camps and addresses?",
|
||||||
|
description: "Here you can find all the necessary information if you want to drive to our camps or send something. If you want to send something please ALWAYS include the full name of your child on the letter/package and please only send it at the time when your kids are staying in camp as we cannot store it for a longer period of time.\n\nWalsrode/Lüneburger Heide - Germany:\nCamp Adventure, Vethem 58, 29664 Walsrode, Germany\nwalsrode@campadventure.de\n\nRegen/Bavarian Forest - Germany:\nCamp Adventure, Badstrasse 18, 94209 Regen\nregen@campadventure.de\n\nBarcelona - Spain:\nBISC International Sailing Center, c/o Camp Adventure, Parc del Fòrum Sota plaça fotovoltàica, 08930 Sant Adrià de Besòs, Barcelona, Spain\nbarcelona@campadventure.de\n\nBath - England:\nUniversity of Bath, c/o Camp Adventure, Claverton Down, Bath BA2 7AY, England\nengland@campadventure.de\n\nRossall - England:\nRossall School, Broadway, Fleetwood, Lancashire FY7 8JW, England\nengland@campadventure.de"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camps",
|
||||||
|
title: "Camps",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "Where do kids and camp counselors come from?",
|
||||||
|
description: "Camp Adventure attaches great importance to internationality. The participants and supervisors in our camps come from many different countries. Last year, for example, we had participants from over 60 different countries and counselors from 25 different nations. Of course, we don't know where they will come from this year. So we are at least as excited as you are.\n\nThrough our office in Hamburg and our branch office in Canada, we reach motivated and committed counselors from all over the world. Canadian and Australian teamers can therefore be found as well as German or Spanish teamers.\n\nDue to the different experiences and cultural backgrounds an indescribably fantastic, international atmosphere is created."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Which languages are spoken in camp?",
|
||||||
|
description: "The main language in all our camps is English. In addition, there is the language of the country in which the camp takes place. As we have our headquarters in Germany, German teamers are always present in all camps in Germany. All announcements and explanations are here therefore always in German and English. Of course, all our teamers with their different nationalities are also available for individual translations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Are there problems if children have low language skills?",
|
||||||
|
description: "No, because there are usually more participants and team members who speak the same language. We know from experience that children are excellent at communicating nonverbally. They often need a few days to warm up to it, but are then very open to other children as well."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Are girls and boys separated?",
|
||||||
|
description: "Girls and boys are accommodated separately in the dormitories/tents. The program is completely mixed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "How big are the camps? How high is the caregiver ratio?",
|
||||||
|
description: "Capacities range from around 30 participants in smaller language camps to a maximum of about 400 children in our camp Lueneburger Heide. However, the maximum capacity is not reached every week. However, a minimum number of participants must be guaranteed in order to run the camp.\n\nIt is important to us that all children are always grouped in small groups of 5-8, with a counselor as a contact person. This way homesickness doesn't stand a chance and despite the size of the camp in their group family, they experience a strong bond on which they can count on!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Should 12-year-olds go to Junior Camp or Senior Camp?",
|
||||||
|
description: "This question is not easy to answer and depends on the individual stage of development of your child. Therefore, as parents, we leave you the opportunity to decide for yourself. In the Junior Camp they belong to the older ones and can explore a lot in a playful way. In the Senior Camp they are the younger ones, who have role models through the older ones, whom they can emulate."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camp-routine",
|
||||||
|
title: "Camp Routine",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "How is the choice of activities/courses in the camps made?",
|
||||||
|
description: "If your child would like to participate in a paid additional course (e.g. horse riding, language course, Survival etc.), this must be booked in advance when registering. In principle, no extra additional courses have to be booked. A program with a variety of activities is of course available to the participants in all camps. The various activities can be chosen by the participants on site in the respective camps. We present the offers to the participants, so that everyone gets an insight into the different courses. The children can then register in the lists of the respective courses."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "What is a hike?",
|
||||||
|
description: "The hike is a 1-3 day walking tour, in which all participants of the Adventure Camp who stay 2 weeks in the camp take part. On this hike the participants will not spend the night in a tent, but either in the open air or under a self-made shelter e.g. from tarpaulins. They will of course be accompanied by their teamers. The hike is a very special experience and a highlight for all participants. For this hike the participants need sturdy shoes and a big backpack."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Can I wash my clothes during the camp?",
|
||||||
|
description: "In principle, participants should bring sufficient clothing and change of clothes for the entire camp period.\n\nOnly in the camps in Lüneburger Heide and Bayerischer Wald a laundry service will be offered for kids staying three weeks or more, which means that a laundry bag (approx. 3 kg) will be washed in the laundry centre of the next village at a price of USD 45. This service can be booked upon registration for three-week camps. Please note that the laundry will be done either after one week or after two weeks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Anti Homesick Adviser",
|
||||||
|
description: "Dear parents\n\nNow it's almost time: In summer your child travels for the first time with Camp Adventure. Maybe it will be the first time that he travels alone without parents or relatives. As we are getting more and more questions, we have decided to put together a small package for you parents with little tips from experts to make everything as easy as possible for you and your child. Follow our tips and your child will have a fantastic holiday, have many new experiences and make friends from all over the world! All these tips have been developed together with the International Camping Fellowship. And the more you think your child will be a \"homesick candidate\" - or your child even claims to be one - the more you consider the following tips."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camp-counselors",
|
||||||
|
title: "Camp Counselors - Our Teamers",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "Who are the camp counselors?",
|
||||||
|
description: "Every year our team is made up of an international mix. The non-profit association Camp Europe e.V. with headquarters in Hamburg and a branch office in Canada takes care of the acquisition of national and international applicants. Since we have about 50% German-speaking children, there are also German carers in every location. But many also come from other countries, such as England, Spain, Canada and Australia, to name just a few."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "How are the teamers trained?",
|
||||||
|
description: "All counselors go through an extensive application process. For a successful application, not only an interesting curriculum vitae and a minimum age of 19 years are sufficient! We conduct a personal interview with each individual in which our employees get a first impression of the applicant.\n\nBefore the camp season, everyone, both the first supervisors (teamers) as well as many recomers, complete a one-week training in which they are prepared for their assignment by trained coaches. They must have a first aid certificate, which may not be older than two years, as well as an internationally flawless police clearance certificate. We know how important the teamers are for a great camp and therefore select them very conscientiously."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "camp-rules",
|
||||||
|
title: "Camp Rules",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "Drugs, Alcohol & Camp?",
|
||||||
|
description: "From our point of view an absolutely unacceptable and indiscutable combination! Due to our cooperation with the association \"Keine Macht den Drogen\" (No power to drugs) and our common opinion that all kinds of drugs do not belong in the hands of children & teenagers, any possession or consumption of drugs is forbidden for teenagers and children in the camp and also outside the camp.\n\nViolations can lead to exclusion or even to criminal charges. The term \"drugs\" also includes cigarettes and alcohol! Through our varied activities, we offer a much better alternative! We would like to make it clear from the outset that we are also against any form of discrimination or \"putting down\". This is - just like violence - immediately prevented by us, in order to offer each young person a relaxed and joyful time in the camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Should I call my kid or write an old-fashioned letter?",
|
||||||
|
description: "We ask all parents to write to their child at least once. This is especially useful at the beginning, as it is a particularly upsetting experience for every child and every teenager when most of the participants receive a letter, but they do not.\n\nPlease note that there is NO public \"camp phone\" available for incoming or outgoing calls. If your child doesn't bring her/his own phone, she/he won't be able to call you. In case of any problems, we will of course contact you immediately.\n\nIf your child brings a mobile phone, we will collect it on arrival and store it with the valuables. Your child's Teamer may hand it over during the phone time after lunch. Please keep in mind: no news is good news (the location manager will contact you if it is necessary due to homesickness or illness). We kindly ask you not to call the office in Hamburg to ask about your kid's health and wellbeing, nor if you would like to know why your child hasn't called you yet. Please use our camp email service for such enquiries.\n\nOur recommendation is the following:\nWe recommend not to call your child (even if he or she has a mobile phone with him or her) and not to tell him or her to call you. Telephoning can in our experience promote homesickness very strongly and your child will be cured thereby if completely immersed in camp life! At noon after lunch, if absolutely necessary, your child can pick up his or her mobile phone from the counselors until the start of next program and make phone calls. Instead, you are welcome to bring a pre-stamped and addressed envelope with you. We will then make sure that your child has enough time to write letters. Since letters and postcards often arrive late at the camps, we also offer the e-mail service. You can send your child max. ONE email per day directly to the camp, which we then print out and give to your child. There is no way for them to reply, but your child will be happy to receive a small message from home. You can find the postal and email address in the info package of the booked camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Are there any prohibited items?",
|
||||||
|
description: "Yes, there are. Not allowed are pocket knives with lockable blades, all weapons, lighters and matches (danger of fire in the forest!). Drugs of any kind, including alcohol and cigarettes, are also included."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "safety",
|
||||||
|
title: "Safety",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "Electronic equipment and valuables",
|
||||||
|
description: "We recommend that you do not take an MP3 player, e-book, tablet, etc. or any valuables with you. On the one hand we do not assume any liability and on the other hand there are no possibilities to charge the devices. We are of the opinion that the camp time is a special experience for the participants if they do not have the headphones in their ears all the time or are busy with their mobile phones. Instead they have the chance to deal with other topics and they find time to dedicate themselves to the new people in the camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "How do you provide safety for the kids?",
|
||||||
|
description: "Before our camp counselors start working with us, we check their police clearance certificates. You must be at least 19 years old to work for us as a teamer. They must also have a \"First Aid Certificate\", which must not be older than two years. In the camps we try to make sure that only adults from our camp or familiar faces are on the campground and that all our carers look after strangers.\n\nWe have many different camp sites. Some of them are fenced in, others are not. There are no armed guards or the like in our camps, as we believe that these conditions create a very insecure feeling. We do not have a high security zone in Germany, Northern Ireland or England, but we keep our eyes open and do everything we can to ensure that all participants have a great time."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Insurance in case of illness?",
|
||||||
|
description: "If your child should fall ill during the camp and medical help is required, he or she will of course be taken to the doctor by our carers and cared for there as well. It is therefore necessary for each participant to take their insurance card with them to the camp. We offer all participants the possibility of taking out liability, casualty & health insurance for travel abroad with us. This covers all costs in case of illness and prevents international children in particular from having to \"advance\" their own cash. You can find more detailed information on insurance in our documents section."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accommodation-catering",
|
||||||
|
title: "Accommodation & Catering",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "How's the food at the camps?",
|
||||||
|
description: "Full board for the entire duration of the camp is of course already included in the camp price. In addition, water and fruit are available for the participants around the clock. For us it is a matter of course to provide one variant for vegetarians and one pork-free with each meal. In case of special allergies or intolerances of your children let us know in advance and we will try to find a solution."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "How is my child accommodated in the camp?",
|
||||||
|
description: "In our Adventure Camp Bayerischer Wald and our Camp Lueneburger Heide, the Juniors (7-12) and the Seniors (12-16) can choose between tents and huts.\n\nThe tents are equipped with a floor and a wooden platform, up to 7 children can share one tent. The participants can make themselves comfortable with sleeping bag and sleeping mat. The wooden huts are equipped with bunk beds and can accommodate 4-8 children. At the other locations, participants will be accommodated in shared rooms in youth hostels, sports centres or boarding schools of private schools. You will find detailed information about the accommodation on the individual camp pages."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "transfers-shuttles",
|
||||||
|
title: "Transfers & Shuttles",
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: "Entry regulations/Travel Consent for group flights",
|
||||||
|
description: "All parents need to fill this out and bring it to camp:\n\nBelow is a summary of the travel requirements for minors from various EU countries traveling with Camp Adventure on group flights. Please note that regulations can change, so it's essential to consult the official resources provided for the most up-to-date information."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Which transfers are offered?",
|
||||||
|
description: "The respective transfer possibilities depend on the period and venue of the camp. Check directly on the respective camp page under \"Arrival & Departure Services\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Where can I find the exact arrival and departure times?",
|
||||||
|
description: "Information about the different arrival and departure times can be found on the respective camp page under \"Arrival & Departure Services\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "How do the transfer costs come about?",
|
||||||
|
description: "When booking a train or air trip, the indicated price includes the arrival and departure as well as the accompaniment by a supervisor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Where can I find the address/driving directions from the camp?",
|
||||||
|
description: "You will receive the exact address and directions of the camp with the Last Travel Information about 3-4 weeks before the camp starts."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
video: {
|
||||||
|
url: "https://www.youtube.com/embed/3NtE5wSwYTo?list=PLSOedrxa1c-bxvH6uuz_oZdIfJkov66wB&disablekb=1",
|
||||||
|
title: "Anti Homesickness Adviser"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy FAQ data
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const faqData = await getFAQData();
|
||||||
|
|
||||||
|
if (!faqData) {
|
||||||
|
return res.status(404).json({ error: "FAQ data not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}:${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(faqData || {}, baseUrl);
|
||||||
|
res.json(processed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("API Error:", err);
|
||||||
|
res.status(500).json({ error: "Error loading FAQ data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy toàn bộ FAQ data
|
||||||
|
exports.getFAQData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const faqData = await getFAQData();
|
||||||
|
|
||||||
|
if (!faqData) {
|
||||||
|
return res.status(404).json({ error: "FAQ data not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(faqData || {}, baseUrl);
|
||||||
|
res.json(processed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting FAQ data:", error);
|
||||||
|
res.status(500).json({ error: "Error loading FAQ data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để seed FAQ data mặc định
|
||||||
|
exports.seed = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Kiểm tra xem đã có FAQ data chưa
|
||||||
|
let faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (faq) {
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
message: "FAQ data already exists. Use update instead."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo FAQ data mới với nội dung đầy đủ
|
||||||
|
faq = new FAQ(getDefaultFAQData());
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ data seeded successfully",
|
||||||
|
data: faq
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error seeding FAQ data:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error seeding FAQ data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render admin view
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Lấy FAQ data hoặc sử dụng mặc định
|
||||||
|
const data = await getFAQData();
|
||||||
|
const faqData = data || getDefaultFAQData();
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
res.render("admin/faq/index", {
|
||||||
|
title: "FAQ Management",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data: faqData,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
// Helper để stringify JSON
|
||||||
|
stringify: (obj) => JSON.stringify(obj),
|
||||||
|
// Helper để parse JSON
|
||||||
|
parse: (str) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in FAQ index:", error);
|
||||||
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật toàn bộ FAQ data
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
hero,
|
||||||
|
sidebarNav,
|
||||||
|
contactBox,
|
||||||
|
faqSections,
|
||||||
|
video
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings nếu cần
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroData = parseJson(hero);
|
||||||
|
const sidebarNavData = parseJson(sidebarNav);
|
||||||
|
const contactBoxData = parseJson(contactBox);
|
||||||
|
const faqSectionsData = parseJson(faqSections);
|
||||||
|
const videoData = parseJson(video);
|
||||||
|
|
||||||
|
// Tìm hoặc tạo FAQ
|
||||||
|
let faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
// Sử dụng data mặc định nếu không có dữ liệu
|
||||||
|
const updateData = {
|
||||||
|
hero: heroData || getDefaultFAQData().hero,
|
||||||
|
sidebarNav: sidebarNavData || getDefaultFAQData().sidebarNav,
|
||||||
|
contactBox: contactBoxData || getDefaultFAQData().contactBox,
|
||||||
|
faqSections: faqSectionsData || getDefaultFAQData().faqSections,
|
||||||
|
video: videoData || getDefaultFAQData().video
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
// Tạo mới
|
||||||
|
faq = new FAQ({
|
||||||
|
name: "default",
|
||||||
|
...updateData
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Cập nhật
|
||||||
|
Object.keys(updateData).forEach(key => {
|
||||||
|
faq[key] = updateData[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "FAQ updated successfully");
|
||||||
|
res.redirect("/admin/faq");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating FAQ:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating FAQ");
|
||||||
|
res.redirect("/admin/faq");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Reset về mặc định
|
||||||
|
exports.reset = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
faq = new FAQ(getDefaultFAQData());
|
||||||
|
} else {
|
||||||
|
// Cập nhật tất cả các trường về mặc định
|
||||||
|
const defaultData = getDefaultFAQData();
|
||||||
|
Object.keys(defaultData).forEach(key => {
|
||||||
|
if (key !== "name") { // Giữ name
|
||||||
|
faq[key] = defaultData[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ data reset to default successfully",
|
||||||
|
data: faq
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error resetting FAQ data:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error resetting FAQ data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Thêm FAQ vào section
|
||||||
|
exports.addFAQ = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sectionId, faqItem } = req.body;
|
||||||
|
|
||||||
|
let faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
faq = new FAQ(getDefaultFAQData());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await faq.addFaqToSection(sectionId, faqItem);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ added successfully",
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error adding FAQ:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error adding FAQ"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Cập nhật FAQ item
|
||||||
|
exports.updateFAQItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sectionId, faqId } = req.params;
|
||||||
|
const { title, description } = req.body;
|
||||||
|
|
||||||
|
// Bảo toàn newlines và whitespace
|
||||||
|
const cleanDescription = description
|
||||||
|
.replace(/\r\n/g, '\n') // Chuẩn hóa newline
|
||||||
|
.replace(/\r/g, '\n'); // Xử lý cả \r
|
||||||
|
|
||||||
|
const faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "FAQ data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await faq.updateFaqItem(sectionId, faqId, {
|
||||||
|
title,
|
||||||
|
description: cleanDescription
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ item updated successfully",
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating FAQ item:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error updating FAQ item"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Xóa FAQ item
|
||||||
|
exports.deleteFAQItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sectionId, faqId } = req.params;
|
||||||
|
|
||||||
|
const faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "FAQ data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await faq.deleteFaqItem(sectionId, faqId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ item deleted successfully",
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting FAQ item:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error deleting FAQ item"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Cập nhật FAQ section
|
||||||
|
exports.updateFAQSection = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sectionId } = req.params;
|
||||||
|
const { title } = req.body;
|
||||||
|
|
||||||
|
const faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "FAQ data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await faq.updateFaqSection(sectionId, { title });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ section updated successfully",
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating FAQ section:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error updating FAQ section"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Thêm FAQ section mới
|
||||||
|
exports.addFAQSection = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { section } = req.body;
|
||||||
|
|
||||||
|
let faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
faq = new FAQ(getDefaultFAQData());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo ID mới nếu chưa có
|
||||||
|
if (!section.id) {
|
||||||
|
section.id = section.title.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đảm bảo section có mảng faqs
|
||||||
|
if (!section.faqs) {
|
||||||
|
section.faqs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
faq.faqSections.push(section);
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ section added successfully",
|
||||||
|
data: faq
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error adding FAQ section:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error adding FAQ section"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Xóa FAQ section
|
||||||
|
exports.deleteFAQSection = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sectionId } = req.params;
|
||||||
|
|
||||||
|
const faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "FAQ data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lọc ra section cần xóa
|
||||||
|
faq.faqSections = faq.faqSections.filter(s => s.id !== sectionId);
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ section deleted successfully",
|
||||||
|
data: faq
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error deleting FAQ section:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error deleting FAQ section"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Reorder FAQ sections
|
||||||
|
exports.reorderFAQSection = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sections } = req.body;
|
||||||
|
|
||||||
|
const faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "FAQ data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo map để tìm section theo id
|
||||||
|
const sectionMap = new Map();
|
||||||
|
faq.faqSections.forEach(section => {
|
||||||
|
sectionMap.set(section.id, section);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tạo mảng mới theo thứ tự mới
|
||||||
|
const newSections = [];
|
||||||
|
for (const sectionId of sections) {
|
||||||
|
const section = sectionMap.get(sectionId);
|
||||||
|
if (section) {
|
||||||
|
newSections.push(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
faq.faqSections = newSections;
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "FAQ sections reordered successfully",
|
||||||
|
data: faq
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error reordering FAQ sections:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error reordering FAQ sections"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API: Cập nhật sidebar navigation
|
||||||
|
exports.updateSidebarNav = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sidebarNav } = req.body;
|
||||||
|
|
||||||
|
let faq = await FAQ.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
faq = new FAQ(getDefaultFAQData());
|
||||||
|
}
|
||||||
|
|
||||||
|
faq.sidebarNav = sidebarNav;
|
||||||
|
await faq.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Sidebar navigation updated successfully",
|
||||||
|
data: faq
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating sidebar navigation:", err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: err.message || "Error updating sidebar navigation"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
178
controllers/footerController.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const Footer = require("../models/footer");
|
||||||
|
|
||||||
|
// Get footer data from MongoDB
|
||||||
|
const getFooterData = async () => {
|
||||||
|
const footer = await Footer.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!footer) {
|
||||||
|
return {
|
||||||
|
logo: {
|
||||||
|
src: '',
|
||||||
|
alt: ''
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: "About GGC",
|
||||||
|
description: "",
|
||||||
|
mapLink: {
|
||||||
|
text: "Check on google map",
|
||||||
|
url: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
text: "",
|
||||||
|
address2: "",
|
||||||
|
mapUrl: "",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
phone: "",
|
||||||
|
hours: "",
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
columns: [],
|
||||||
|
social: {
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
copyright: {
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return footer.toObject();
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get footer data
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Lấy footer data
|
||||||
|
const footer = await getFooterData();
|
||||||
|
|
||||||
|
// Xử lý URL cho logo và các hình ảnh khác
|
||||||
|
const processedData = addBaseUrlToImages(footer);
|
||||||
|
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("API Error:", err);
|
||||||
|
res.status(500).json({ error: "Error loading footer data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy toàn bộ footer data
|
||||||
|
exports.getFooterData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const footerData = await getFooterData();
|
||||||
|
const processed = addBaseUrlToImages(footerData);
|
||||||
|
res.json(processed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting footer data:", error);
|
||||||
|
res.status(500).json({ error: "Error loading footer data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render admin view
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getFooterData();
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
// Ensure image paths are absolute for admin preview
|
||||||
|
const processedData = addBaseUrlToImages(data);
|
||||||
|
|
||||||
|
res.render("admin/footer/index", {
|
||||||
|
|
||||||
|
title: "Footer Management",
|
||||||
|
data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in footer index:", error);
|
||||||
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật dữ liệu footer
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
let footerData = req.body;
|
||||||
|
if (footerData.footerJson) {
|
||||||
|
try {
|
||||||
|
footerData = JSON.parse(footerData.footerJson);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Invalid footerJson payload, falling back to req.body');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tìm footer hiện có hoặc tạo mới nếu không tồn tại
|
||||||
|
let footer = await Footer.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (!footer) {
|
||||||
|
footer = new Footer({
|
||||||
|
name: "default",
|
||||||
|
about: footerData.about || {
|
||||||
|
title: "About GGC",
|
||||||
|
description: "",
|
||||||
|
mapLink: { text: "Check on google map", url: "" },
|
||||||
|
},
|
||||||
|
address: footerData.address || {
|
||||||
|
text: "",
|
||||||
|
address2: "",
|
||||||
|
mapUrl: "",
|
||||||
|
link2: "",
|
||||||
|
},
|
||||||
|
contact: footerData.contact || { phone: "", hours: "", email: "" },
|
||||||
|
columns: Array.isArray(footerData.columns) ? footerData.columns : [],
|
||||||
|
social: footerData.social || { links: [] },
|
||||||
|
copyright: footerData.copyright || { text: "" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Cập nhật các trường
|
||||||
|
if (footerData.about) {
|
||||||
|
footer.about = {
|
||||||
|
title: footerData.about.title || footer.about?.title || "About GGC",
|
||||||
|
description: footerData.about.description || footer.about?.description || "",
|
||||||
|
mapLink: {
|
||||||
|
text: footerData.about.mapLink?.text || footer.about?.mapLink?.text || "Check on google map",
|
||||||
|
url: footerData.about.mapLink?.url || footer.about?.mapLink?.url || ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (footerData.address) {
|
||||||
|
// Đảm bảo address2 tồn tại để tránh undefined trong view/schema
|
||||||
|
footer.address = {
|
||||||
|
...(footer.address?.toObject
|
||||||
|
? footer.address.toObject()
|
||||||
|
: footer.address),
|
||||||
|
...footerData.address,
|
||||||
|
address2:
|
||||||
|
footerData.address.address2 !== undefined
|
||||||
|
? footerData.address.address2
|
||||||
|
: footer.address?.address2 || "",
|
||||||
|
link2:
|
||||||
|
footerData.address.link2 !== undefined
|
||||||
|
? footerData.address.link2
|
||||||
|
: footer.address?.link2 || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (footerData.contact) footer.contact = footerData.contact;
|
||||||
|
if (Array.isArray(footerData.columns))
|
||||||
|
footer.columns = footerData.columns;
|
||||||
|
if (footerData.social && Array.isArray(footerData.social.links))
|
||||||
|
footer.social = footerData.social;
|
||||||
|
if (footerData.copyright && typeof footerData.copyright.text === "string")
|
||||||
|
footer.copyright = footerData.copyright;
|
||||||
|
}
|
||||||
|
|
||||||
|
await footer.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "Footer updated successfully");
|
||||||
|
|
||||||
|
// Redirect back to the active tab
|
||||||
|
const activeTab = req.body.activeTab || 'about';
|
||||||
|
res.redirect(`/admin/footer?activeTab=${activeTab}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating footer:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating footer");
|
||||||
|
res.redirect("/admin/footer");
|
||||||
|
}
|
||||||
|
};
|
||||||
44
controllers/formController.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const formController = {
|
||||||
|
// Display form management page
|
||||||
|
index: async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.render('admin/form/index', {
|
||||||
|
layout: 'layouts/admin',
|
||||||
|
title: 'Quản lý Form',
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading form management page:', error);
|
||||||
|
res.status(500).render('error', {
|
||||||
|
message: 'Lỗi khi tải trang quản lý form',
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update default form settings
|
||||||
|
updateDefaultForm: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const formData = req.body;
|
||||||
|
|
||||||
|
// Here you would typically save form configuration to database or file
|
||||||
|
// For now, just return success response
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cập nhật form thành công'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating form:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Lỗi khi cập nhật form'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = formController;
|
||||||
347
controllers/headerController.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||||
|
const Header = require('../models/header');
|
||||||
|
const Menu = require('../models/menuHeader');
|
||||||
|
|
||||||
|
// Helper function để thêm title và url cho programmes
|
||||||
|
const addProgrammeDetails = (programmes, menuUrl) => {
|
||||||
|
return programmes.map(prog => ({
|
||||||
|
...prog,
|
||||||
|
title: prog.name,
|
||||||
|
url: `${menuUrl}${prog.code}/`
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function để xử lý menu tree cho API (đơn giản hóa, map menuid thành id)
|
||||||
|
const processMenuTreeForAPI = (menuTree) => {
|
||||||
|
return menuTree.map(item => {
|
||||||
|
const processedItem = {
|
||||||
|
id: item.menuid, // Map menuid to id for frontend
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
order: item.order,
|
||||||
|
parent: item.parent || null,
|
||||||
|
type: item.type,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Đệ quy cho children
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
processedItem.children = processMenuTreeForAPI(item.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedItem;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function để xử lý menu tree và thêm programme details (cho admin)
|
||||||
|
const processMenuTree = (menuTree) => {
|
||||||
|
return menuTree.map(item => {
|
||||||
|
const processedItem = { ...item };
|
||||||
|
|
||||||
|
// Nếu có programmes, thêm title và url
|
||||||
|
if (item.programmes && item.programmes.length > 0) {
|
||||||
|
processedItem.programmes = addProgrammeDetails(item.programmes, item.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đệ quy cho children
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
processedItem.children = processMenuTree(item.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedItem;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get header data from MongoDB
|
||||||
|
const getHeaderData = async () => {
|
||||||
|
const header = await Header.findOne({ name: 'default' });
|
||||||
|
|
||||||
|
if (!header) {
|
||||||
|
return {
|
||||||
|
topbar: {
|
||||||
|
contactInfo: {
|
||||||
|
phone: '',
|
||||||
|
email: ''
|
||||||
|
},
|
||||||
|
links: []
|
||||||
|
},
|
||||||
|
mainMenu: [],
|
||||||
|
logo: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to plain object to allow modifications
|
||||||
|
const headerData = header.toObject();
|
||||||
|
|
||||||
|
// Lấy menu tree từ collection menuHeader (đơn giản, không có programmes)
|
||||||
|
try {
|
||||||
|
const menuTree = await Menu.getMenuTree();
|
||||||
|
// Xử lý menu tree để map menuid thành id cho frontend
|
||||||
|
headerData.mainMenu = processMenuTreeForAPI(menuTree);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting menu tree:', error);
|
||||||
|
headerData.mainMenu = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return headerData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get header data
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Lấy header data
|
||||||
|
const header = await getHeaderData();
|
||||||
|
|
||||||
|
// Xử lý URL cho logo và các hình ảnh khác
|
||||||
|
const processedData = addBaseUrlToImages(header);
|
||||||
|
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API Error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading header data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy menu tree cho frontend (public API)
|
||||||
|
exports.getMenuTreeAPI = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const menuTree = await Menu.getMenuTree();
|
||||||
|
// Xử lý menu tree để map menuid thành id cho frontend
|
||||||
|
const processedMenuTree = processMenuTreeForAPI(menuTree);
|
||||||
|
res.json(processedMenuTree);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting menu tree:', error);
|
||||||
|
res.status(500).json({ error: 'Error loading menu tree' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy menu tree (cho admin)
|
||||||
|
exports.getMenuTree = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const menuTree = await Menu.getMenuTree();
|
||||||
|
res.json(menuTree);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting menu tree:', error);
|
||||||
|
res.status(500).json({ error: 'Error loading menu tree' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy programmes theo menu ID
|
||||||
|
exports.getProgrammesByMenuId = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { menuId } = req.params;
|
||||||
|
const programmes = await Header.getProgrammesByMenuId(menuId);
|
||||||
|
|
||||||
|
// Lấy menu item để có URL
|
||||||
|
const menuItem = await Menu.findOne({ menuid: menuId });
|
||||||
|
if (menuItem) {
|
||||||
|
const programmesWithDetails = addProgrammeDetails(programmes, menuItem.url);
|
||||||
|
res.json(programmesWithDetails);
|
||||||
|
} else {
|
||||||
|
res.json(programmes);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting programmes by menu ID:', error);
|
||||||
|
res.status(500).json({ error: 'Error loading programmes' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy toàn bộ header data
|
||||||
|
exports.getHeaderData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const headerData = await getHeaderData();
|
||||||
|
res.json(headerData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting header data:', error);
|
||||||
|
res.status(500).json({ error: 'Error loading header data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy menu item theo ID
|
||||||
|
exports.getMenuItem = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { menuId } = req.params;
|
||||||
|
const Menu = require('../models/menuHeader');
|
||||||
|
const menuItem = await Menu.findOne({ menuid: menuId });
|
||||||
|
|
||||||
|
if (!menuItem) {
|
||||||
|
return res.status(404).json({ error: 'Menu item not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu là level type và có fetch = true, lấy programmes
|
||||||
|
if (menuItem.type === 'level' && menuItem.fetch) {
|
||||||
|
const programmes = await Menu.getProgrammesByMenuId(menuItem.menuid);
|
||||||
|
menuItem.programmes = addProgrammeDetails(programmes, menuItem.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(menuItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting menu item:', error);
|
||||||
|
res.status(500).json({ error: 'Error loading menu item' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render admin view
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getHeaderData();
|
||||||
|
|
||||||
|
res.render('admin/header/index', {
|
||||||
|
title: 'Header Management',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in header index:', error);
|
||||||
|
req.flash('error_msg', 'An error occurred while loading the page');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update header (chỉ cập nhật topbar và logo)
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const headerData = req.body;
|
||||||
|
|
||||||
|
// Tìm và cập nhật header
|
||||||
|
const header = await Header.findOne({ name: 'default' });
|
||||||
|
if (!header) {
|
||||||
|
req.flash('error_msg', 'Header not found');
|
||||||
|
return res.redirect('/admin/header');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật từng phần
|
||||||
|
if (headerData.topbarJson) {
|
||||||
|
header.topbar = JSON.parse(headerData.topbarJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerData.logo) {
|
||||||
|
header.logo = headerData.logo;
|
||||||
|
}
|
||||||
|
header.updatedAt = new Date();
|
||||||
|
await header.save();
|
||||||
|
|
||||||
|
// Process menu updates if any
|
||||||
|
let menuUpdateCount = 0;
|
||||||
|
let menuErrorCount = 0;
|
||||||
|
|
||||||
|
if (headerData.menuUpdates) {
|
||||||
|
try {
|
||||||
|
const updates = JSON.parse(headerData.menuUpdates);
|
||||||
|
|
||||||
|
if (Array.isArray(updates) && updates.length > 0) {
|
||||||
|
const Menu = require('../models/menuHeader');
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
try {
|
||||||
|
const { menuid, title, order, type, parent, fetch, isActive } = update;
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
title: title,
|
||||||
|
order: order,
|
||||||
|
type: type,
|
||||||
|
parent: parent
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add fetch field if provided
|
||||||
|
if (fetch !== undefined) {
|
||||||
|
updateData.fetch = fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add isActive field if provided
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
updateData.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Menu.findOneAndUpdate(
|
||||||
|
{ menuid: menuid },
|
||||||
|
updateData,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
menuUpdateCount++;
|
||||||
|
} else {
|
||||||
|
console.warn(`Menu item not found for update: ${menuid}`);
|
||||||
|
menuErrorCount++;
|
||||||
|
}
|
||||||
|
} catch (innerErr) {
|
||||||
|
console.error(`Error updating menu item ${update.menuid}:`, innerErr);
|
||||||
|
menuErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error processing menu updates:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let flashMsg = 'Header updated successfully.';
|
||||||
|
if (menuUpdateCount > 0) {
|
||||||
|
flashMsg += ` Updated ${menuUpdateCount} menu items.`;
|
||||||
|
}
|
||||||
|
if (menuErrorCount > 0) {
|
||||||
|
req.flash('error_msg', `Updated ${menuUpdateCount} items, but failed to update ${menuErrorCount} items. Check logs.`);
|
||||||
|
} else {
|
||||||
|
req.flash('success_msg', flashMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to the active tab
|
||||||
|
const activeTab = req.body.activeTab || 'topbar';
|
||||||
|
res.redirect(`/admin/header?activeTab=${activeTab}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating header:', error);
|
||||||
|
req.flash('error_msg', 'Error updating header: ' + error.message);
|
||||||
|
res.redirect('/admin/header');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update menu structure (order and parent)
|
||||||
|
exports.updateMenu = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { updates } = req.body;
|
||||||
|
|
||||||
|
if (!updates || !Array.isArray(updates)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid updates data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const Menu = require('../models/menuHeader');
|
||||||
|
|
||||||
|
// Update each menu item
|
||||||
|
for (const update of updates) {
|
||||||
|
const { menuid, title, order, type, parent, fetch, isActive } = update;
|
||||||
|
|
||||||
|
console.log(menuid, title, order, type, parent, fetch, isActive);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
title: title,
|
||||||
|
order: order,
|
||||||
|
type: type,
|
||||||
|
parent: parent
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add fetch field if provided (for level type menus)
|
||||||
|
if (fetch !== undefined) {
|
||||||
|
updateData.fetch = fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add isActive field if provided
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
updateData.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Menu.findOneAndUpdate(
|
||||||
|
{ menuid: menuid },
|
||||||
|
updateData,
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating menu structure:', error);
|
||||||
|
req.flash('error_msg', 'Error updating menu structure: ' + error.message);
|
||||||
|
res.redirect('/admin/header');
|
||||||
|
}
|
||||||
|
};
|
||||||
315
controllers/homeController.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
const { addBaseUrlToImages } = require('../utils/imageHelper');
|
||||||
|
const Home = require('../models/home');
|
||||||
|
|
||||||
|
// -------------------- Helper Functions --------------------
|
||||||
|
|
||||||
|
// Get home data from MongoDB
|
||||||
|
const getHomeData = async () => {
|
||||||
|
const home = await Home.findOne().sort({ updatedAt: -1 }).lean();
|
||||||
|
return home || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get default home data structure
|
||||||
|
const getDefaultHomeData = () => ({
|
||||||
|
hero: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
backgroundImage: '',
|
||||||
|
button: { label: 'Book Your Adventure', href: '/booking' },
|
||||||
|
contactBox: {
|
||||||
|
welcomeText: '',
|
||||||
|
phone: { label: 'Call us', number: '', href: '' },
|
||||||
|
email: { label: 'Email', address: '', href: '' },
|
||||||
|
workingHours: { label: 'Working Hours', hours: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
description: '',
|
||||||
|
images: { mainImage1: '', mainImage2: '', avatars: [] },
|
||||||
|
features: [],
|
||||||
|
quote: '',
|
||||||
|
button: { label: '', href: '' },
|
||||||
|
stats: { customerCount: 0, customerLabel: '' }
|
||||||
|
},
|
||||||
|
missionVision: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
backgroundImage: '',
|
||||||
|
cards: []
|
||||||
|
},
|
||||||
|
whyChooseUs: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
description: '',
|
||||||
|
button: { label: '', href: '' },
|
||||||
|
features: [],
|
||||||
|
tags: [],
|
||||||
|
cta: { text: '', linkText: '', linkHref: '' }
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
cards: []
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
contact: { title: '', info: '' },
|
||||||
|
questions: []
|
||||||
|
},
|
||||||
|
partners: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
backgroundImage: '',
|
||||||
|
logos: [],
|
||||||
|
cta: { badge: '', text: '', linkText: '', linkHref: '' }
|
||||||
|
},
|
||||||
|
programs: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
button: { label: '', href: '' },
|
||||||
|
card: {
|
||||||
|
pricePrefix: 'from',
|
||||||
|
priceSuffix: 'USD',
|
||||||
|
buttonLabel: 'Camp Detail',
|
||||||
|
buttonHref: '/camp-profiles'
|
||||||
|
},
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
newsletter: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
decorativeImage: '',
|
||||||
|
button: {
|
||||||
|
label: '',
|
||||||
|
placeholder: '',
|
||||||
|
href: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
latestPosts: {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
searchPlaceholder: '',
|
||||||
|
sidebarTitle: '',
|
||||||
|
blogPosts: [],
|
||||||
|
sidebarPosts: [],
|
||||||
|
featuredCard: { image: '', title: '', description: '' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// -------------------- Admin Exports --------------------
|
||||||
|
|
||||||
|
|
||||||
|
// Display home management page
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Fetch Home data
|
||||||
|
let data = await getHomeData();
|
||||||
|
|
||||||
|
// If no data exists, use default
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
data = getDefaultHomeData();
|
||||||
|
} else {
|
||||||
|
// Merge with defaults to ensure all fields exist
|
||||||
|
const defaultData = getDefaultHomeData();
|
||||||
|
|
||||||
|
// Ensure all sections exist with defaults
|
||||||
|
data.hero = data.hero || defaultData.hero;
|
||||||
|
data.about = data.about || defaultData.about;
|
||||||
|
data.missionVision = data.missionVision || defaultData.missionVision;
|
||||||
|
data.whyChooseUs = data.whyChooseUs || defaultData.whyChooseUs;
|
||||||
|
data.activities = data.activities || defaultData.activities;
|
||||||
|
data.faq = data.faq || defaultData.faq;
|
||||||
|
data.partners = data.partners || defaultData.partners;
|
||||||
|
data.programs = data.programs || defaultData.programs;
|
||||||
|
data.newsletter = data.newsletter || defaultData.newsletter;
|
||||||
|
data.latestPosts = data.latestPosts || defaultData.latestPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
res.render('admin/home/index', {
|
||||||
|
layout: 'layouts/main',
|
||||||
|
title: 'Home Management',
|
||||||
|
data,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Home index error:', err);
|
||||||
|
req.flash('error_msg', 'Error loading home data');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update home data
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get current data
|
||||||
|
const currentData = await getHomeData();
|
||||||
|
|
||||||
|
// Create updated data object
|
||||||
|
const updatedData = { ...(currentData.toObject ? currentData.toObject() : currentData) };
|
||||||
|
|
||||||
|
// Update Hero section data (from Welcome tab)
|
||||||
|
if (req.body.heroTitle || req.body.heroDescription || req.body.heroBackgroundImage) {
|
||||||
|
updatedData.hero = {
|
||||||
|
title: req.body.heroTitle || '',
|
||||||
|
description: req.body.heroDescription || '',
|
||||||
|
backgroundImage: req.body.heroBackgroundImage || '',
|
||||||
|
button: {
|
||||||
|
label: req.body.heroButtonLabel || 'Book Your Adventure',
|
||||||
|
href: req.body.heroButtonHref || '/booking'
|
||||||
|
},
|
||||||
|
contactBox: {
|
||||||
|
welcomeText: req.body.heroContactWelcome || '',
|
||||||
|
phone: {
|
||||||
|
label: 'Call us',
|
||||||
|
number: req.body.heroContactPhone || '',
|
||||||
|
href: req.body.heroContactPhone ? `tel:${req.body.heroContactPhone}` : ''
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
label: 'Email',
|
||||||
|
address: req.body.heroContactEmail || '',
|
||||||
|
href: req.body.heroContactEmail ? `mailto:${req.body.heroContactEmail}` : ''
|
||||||
|
},
|
||||||
|
workingHours: {
|
||||||
|
label: 'Working Hours',
|
||||||
|
hours: req.body.heroContactHours || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Why Choose Us section
|
||||||
|
if (req.body.whyChooseUsTitle || req.body.whyChooseUsSubtitle) {
|
||||||
|
updatedData.whyChooseUs = {
|
||||||
|
...(updatedData.whyChooseUs || {}),
|
||||||
|
title: req.body.whyChooseUsTitle || '',
|
||||||
|
subtitle: req.body.whyChooseUsSubtitle || '',
|
||||||
|
description: req.body.whyChooseUsDescription || '',
|
||||||
|
button: {
|
||||||
|
label: req.body.whyChooseUsButtonLabel || '',
|
||||||
|
href: req.body.whyChooseUsButtonHref || ''
|
||||||
|
},
|
||||||
|
features: updatedData.whyChooseUs?.features || [],
|
||||||
|
tags: updatedData.whyChooseUs?.tags || [],
|
||||||
|
cta: updatedData.whyChooseUs?.cta || { text: '', linkText: '', linkHref: '' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Home sections (new camp structure only)
|
||||||
|
const sections = ['hero', 'about', 'missionVision', 'whyChooseUs', 'activities', 'faq',
|
||||||
|
'partners', 'programs', 'newsletter', 'latestPosts'];
|
||||||
|
const errors = [];
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
// Process each section
|
||||||
|
for (const section of sections) {
|
||||||
|
try {
|
||||||
|
if (!req.body[section]) {
|
||||||
|
console.warn(`No data for section: ${section}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON data from form
|
||||||
|
const newSectionData = JSON.parse(req.body[section]);
|
||||||
|
|
||||||
|
// Check for changes
|
||||||
|
const currentSectionData = currentData[section];
|
||||||
|
const sectionHasChanges = JSON.stringify(newSectionData) !== JSON.stringify(currentSectionData);
|
||||||
|
|
||||||
|
if (sectionHasChanges) {
|
||||||
|
updatedData[section] = newSectionData;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing section ${section}:`, error);
|
||||||
|
errors.push(`Error processing ${section} data: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
if (errors.length > 0) {
|
||||||
|
req.flash('error_msg', `Data processing error: ${errors[0]}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are changes
|
||||||
|
if (!hasChanges) {
|
||||||
|
req.flash('info_msg', 'No changes were made');
|
||||||
|
return req.session.save(() => res.redirect('/admin/home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create document
|
||||||
|
try {
|
||||||
|
if (currentData._id) {
|
||||||
|
await Home.findByIdAndUpdate(currentData._id, updatedData, { new: true });
|
||||||
|
} else {
|
||||||
|
await Home.create(updatedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Home data updated successfully');
|
||||||
|
return req.session.save(() => res.redirect('/admin/home'));
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('Database error:', dbError);
|
||||||
|
req.flash('error_msg', `Database error: ${dbError.message || 'Unknown'}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/home'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update error:', err);
|
||||||
|
req.flash('error_msg', `Update error: ${err.message || 'Unknown'}`);
|
||||||
|
return req.session.save(() => res.redirect('/admin/home'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------- Public API Exports --------------------
|
||||||
|
|
||||||
|
// API to get home data for frontend
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const homeData = await getHomeData();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(homeData, baseUrl);
|
||||||
|
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Home API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading home data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API to get hero data for frontend
|
||||||
|
exports.apiHero = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const homeData = await getHomeData();
|
||||||
|
const heroData = homeData?.hero;
|
||||||
|
|
||||||
|
if (!heroData) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Hero data not found',
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(heroData, baseUrl);
|
||||||
|
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Hero API error:', err);
|
||||||
|
res.status(500).json({ error: 'Error loading hero data' });
|
||||||
|
}
|
||||||
|
};
|
||||||
495
controllers/insuranceController.js
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
const Insurance = require("../models/insurance");
|
||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
|
||||||
|
// API để lấy insurance data (cho frontend)
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.query.lang || "en";
|
||||||
|
|
||||||
|
// Sử dụng getDefault để đảm bảo luôn có data
|
||||||
|
const insurance = await Insurance.getDefault(language);
|
||||||
|
|
||||||
|
// Trả về data với cấu trúc mới
|
||||||
|
const insuranceData = insurance.toObject();
|
||||||
|
|
||||||
|
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
|
||||||
|
|
||||||
|
// Trả về trực tiếp hero, page, content (không wrap trong object)
|
||||||
|
res.json({
|
||||||
|
hero: processedData.hero,
|
||||||
|
page: processedData.page,
|
||||||
|
content: processedData.content
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading insurance data",
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy toàn bộ insurance data (cho admin)
|
||||||
|
exports.getInsuranceData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.query.lang || "en";
|
||||||
|
const insurance = await Insurance.findOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (!insurance) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Insurance data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insuranceData = insurance.toObject();
|
||||||
|
|
||||||
|
// Thêm base URL vào đường dẫn ảnh
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: processedData
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting insurance data:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading insurance data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy data theo ngôn ngữ
|
||||||
|
exports.getByLanguage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.params.lang || "en";
|
||||||
|
|
||||||
|
const insurance = await Insurance.findOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (!insurance) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Insurance data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insuranceData = insurance.toObject();
|
||||||
|
|
||||||
|
// Thêm base URL vào đường dẫn ảnh
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(insuranceData, baseUrl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hero: processedData.hero,
|
||||||
|
page: processedData.page,
|
||||||
|
content: processedData.content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting insurance by language:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading insurance data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render admin view
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Luôn đảm bảo có default data
|
||||||
|
const insurance = await Insurance.getDefault("en");
|
||||||
|
const data = insurance.toObject();
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
res.render("admin/insurance/index", {
|
||||||
|
title: "Insurance Management",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data,
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in insurance index:", error);
|
||||||
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed data từ JSON file (cấu trúc mới)
|
||||||
|
exports.seed = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Đọc file JSON
|
||||||
|
const jsonPath = path.join(__dirname, '../data/insurance.json');
|
||||||
|
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
|
||||||
|
|
||||||
|
console.log('Seeding insurance from JSON...');
|
||||||
|
|
||||||
|
// Migrate từ cấu trúc cũ sang mới
|
||||||
|
const insurance = await Insurance.migrateFromJson(jsonData, "en");
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Insurance data seeded successfully",
|
||||||
|
data: {
|
||||||
|
id: insurance._id,
|
||||||
|
hero: insurance.hero,
|
||||||
|
page: insurance.page,
|
||||||
|
content: insurance.content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding insurance:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Error seeding insurance data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API preview cho admin (tạo HTML preview)
|
||||||
|
exports.preview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, page, content } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroData = parseJson(hero) || {};
|
||||||
|
const pageData = parseJson(page) || {};
|
||||||
|
const contentData = parseJson(content) || {};
|
||||||
|
|
||||||
|
// Thêm base URL vào đường dẫn ảnh cho preview
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
|
||||||
|
|
||||||
|
// Render preview HTML
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${pageData.title || 'Insurance Preview'}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; }
|
||||||
|
.hero-section {
|
||||||
|
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
|
||||||
|
url('${processedHeroData.backgroundImage || ''}');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
color: white;
|
||||||
|
padding: 100px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.content-section {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.content-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="hero-section">
|
||||||
|
<h1>${heroData.title || 'Insurance'}</h1>
|
||||||
|
<p>${heroData.subtitle || ''}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<h2>${pageData.title || 'Insurance Information'}</h2>
|
||||||
|
${pageData.divider !== false ? '<hr>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section -->
|
||||||
|
<div class="content-section">
|
||||||
|
<div class="container">
|
||||||
|
${renderContentItems(contentData.content || [])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.send(html);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating preview:", error);
|
||||||
|
res.status(500).send("Error generating preview");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function để render content items
|
||||||
|
function renderContentItems(contentItems) {
|
||||||
|
if (!Array.isArray(contentItems) || contentItems.length === 0) {
|
||||||
|
return '<p>No content available.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentItems.map(item => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'header':
|
||||||
|
return `<h${item.level || 2} class="content-item">${item.text}</h${item.level || 2}>`;
|
||||||
|
|
||||||
|
case 'paragraph':
|
||||||
|
return `<p class="content-item">${item.text}</p>`;
|
||||||
|
|
||||||
|
case 'section':
|
||||||
|
return `
|
||||||
|
<div class="content-item">
|
||||||
|
<h3>${item.title}</h3>
|
||||||
|
<p>${item.content}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
case 'list':
|
||||||
|
const listItems = (item.items || []).map(li => `<li>${li}</li>`).join('');
|
||||||
|
return `<ul class="content-item">${listItems}</ul>`;
|
||||||
|
|
||||||
|
case 'note':
|
||||||
|
return `<div class="alert alert-info content-item">${item.text}</div>`;
|
||||||
|
|
||||||
|
case 'embed':
|
||||||
|
if (item.source === 'youtube') {
|
||||||
|
return `
|
||||||
|
<div class="content-item">
|
||||||
|
<iframe width="${item.width || 560}" height="${item.height || 315}"
|
||||||
|
src="${item.url || item.embed}"
|
||||||
|
frameborder="0" allowfullscreen></iframe>
|
||||||
|
${item.caption ? `<p class="text-muted mt-2">${item.caption}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// API để tạo insurance mới (cho các ngôn ngữ khác)
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, page, content, language } = req.body;
|
||||||
|
|
||||||
|
if (!language) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Language is required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra đã tồn tại chưa
|
||||||
|
const existing = await Insurance.findOne({ name: "default", language: language });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Insurance already exists for this language"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON nếu cần
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const insurance = new Insurance({
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
hero: parseJson(hero) || {},
|
||||||
|
page: parseJson(page) || {},
|
||||||
|
content: parseJson(content) || {},
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
migratedFromOldStructure: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await insurance.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Insurance created successfully for language: " + language,
|
||||||
|
data: insurance
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating insurance:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Error creating insurance"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật dữ liệu insurance (CẬP NHẬT CẤU TRÚC MỚI)
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, page, content } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse all data với cấu trúc mới
|
||||||
|
const heroData = parseJson(hero) || {};
|
||||||
|
const pageData = parseJson(page) || {};
|
||||||
|
const contentData = parseJson(content) || {};
|
||||||
|
|
||||||
|
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
|
||||||
|
function extractYouTubeId(url) {
|
||||||
|
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/;
|
||||||
|
const match = url.match(regex);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentData && Array.isArray(contentData.content)) {
|
||||||
|
contentData.content.forEach(item => {
|
||||||
|
if (item.type === 'embed' && item.source === 'youtube') {
|
||||||
|
if (item.url && item.url.includes('watch?v=')) {
|
||||||
|
const videoId = extractYouTubeId(item.url);
|
||||||
|
if (videoId) {
|
||||||
|
item.url = `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
item.videoId = videoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.embed && item.embed.includes('watch?v=')) {
|
||||||
|
const videoId = extractYouTubeId(item.embed);
|
||||||
|
if (videoId) {
|
||||||
|
item.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
item.videoId = videoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tìm hoặc tạo insurance
|
||||||
|
let insurance = await Insurance.findOne({ name: "default", language: "en" });
|
||||||
|
|
||||||
|
if (!insurance) {
|
||||||
|
insurance = new Insurance({
|
||||||
|
name: "default",
|
||||||
|
language: "en",
|
||||||
|
hero: heroData,
|
||||||
|
page: pageData,
|
||||||
|
content: contentData,
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
insurance.hero = heroData;
|
||||||
|
insurance.page = pageData;
|
||||||
|
insurance.content = contentData;
|
||||||
|
insurance.version = "2.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
await insurance.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "Insurance updated successfully");
|
||||||
|
res.redirect("/admin/insurance");
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating insurance:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating insurance");
|
||||||
|
res.redirect("/admin/insurance");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để xóa insurance (theo ngôn ngữ)
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.params.lang;
|
||||||
|
|
||||||
|
if (!language) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Language parameter is required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Không cho phép xóa tiếng Anh mặc định
|
||||||
|
if (language === "en") {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Cannot delete default English insurance data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Insurance.deleteOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Insurance not found for this language"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Insurance deleted successfully for language: " + language
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting insurance:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Error deleting insurance"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
228
controllers/pageController.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
|
||||||
|
const slugify = require('slugify');
|
||||||
|
|
||||||
|
// Hiển thị tất cả các trang
|
||||||
|
exports.getAllPages = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const content = readJsonFile('content');
|
||||||
|
const pages = content.pages || [];
|
||||||
|
|
||||||
|
res.render('admin/pages/index', {
|
||||||
|
title: 'Quản lý trang',
|
||||||
|
pages
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading page list');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hiển thị form tạo trang mới
|
||||||
|
exports.getAddPage = (req, res) => {
|
||||||
|
res.render('admin/pages/add', {
|
||||||
|
title: 'Thêm trang mới'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý tạo trang mới
|
||||||
|
exports.addPage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, content } = req.body;
|
||||||
|
|
||||||
|
// Kiểm tra dữ liệu
|
||||||
|
if (!title || !content) {
|
||||||
|
req.flash('error_msg', 'Please fill in all required fields');
|
||||||
|
return res.redirect('/admin/pages/add');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo slug từ tiêu đề
|
||||||
|
const slug = slugify(title, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
locale: 'vi'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lấy dữ liệu hiện tại
|
||||||
|
const contentData = readJsonFile('content');
|
||||||
|
const pages = contentData.pages || [];
|
||||||
|
|
||||||
|
// Kiểm tra slug đã tồn tại chưa
|
||||||
|
const existingPage = pages.find(page => page.slug === slug);
|
||||||
|
if (existingPage) {
|
||||||
|
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
|
||||||
|
return res.redirect('/admin/pages/add');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo trang mới
|
||||||
|
const newPage = {
|
||||||
|
id: Date.now().toString(), // Sử dụng timestamp làm ID
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thêm trang mới vào danh sách
|
||||||
|
pages.push(newPage);
|
||||||
|
contentData.pages = pages;
|
||||||
|
|
||||||
|
// Lưu lại dữ liệu
|
||||||
|
writeJsonFile('content', contentData);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'New page created successfully');
|
||||||
|
res.redirect('/admin/pages');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error creating new page');
|
||||||
|
res.redirect('/admin/pages/add');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hiển thị form chỉnh sửa trang
|
||||||
|
exports.getEditPage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pageId = req.params.id;
|
||||||
|
const contentData = readJsonFile('content');
|
||||||
|
const pages = contentData.pages || [];
|
||||||
|
|
||||||
|
const page = pages.find(p => p.id === pageId);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
req.flash('error_msg', 'Page not found');
|
||||||
|
return res.redirect('/admin/pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('admin/pages/edit', {
|
||||||
|
title: 'Chỉnh sửa trang',
|
||||||
|
page
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading page');
|
||||||
|
res.redirect('/admin/pages');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý chỉnh sửa trang
|
||||||
|
exports.updatePage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pageId = req.params.id;
|
||||||
|
const { title, content } = req.body;
|
||||||
|
|
||||||
|
// Kiểm tra dữ liệu
|
||||||
|
if (!title || !content) {
|
||||||
|
req.flash('error_msg', 'Please fill in all required fields');
|
||||||
|
return res.redirect(`/admin/pages/edit/${pageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy dữ liệu hiện tại
|
||||||
|
const contentData = readJsonFile('content');
|
||||||
|
const pages = contentData.pages || [];
|
||||||
|
|
||||||
|
// Tìm trang cần cập nhật
|
||||||
|
const pageIndex = pages.findIndex(p => p.id === pageId);
|
||||||
|
|
||||||
|
if (pageIndex === -1) {
|
||||||
|
req.flash('error_msg', 'Page not found');
|
||||||
|
return res.redirect('/admin/pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
|
||||||
|
// Kiểm tra nếu tiêu đề thay đổi thì cập nhật slug
|
||||||
|
let newSlug = page.slug;
|
||||||
|
if (page.title !== title) {
|
||||||
|
newSlug = slugify(title, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
locale: 'vi'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kiểm tra slug đã tồn tại chưa (trừ trang hiện tại)
|
||||||
|
const existingPage = pages.find(p => p.slug === newSlug && p.id !== pageId);
|
||||||
|
if (existingPage) {
|
||||||
|
req.flash('error_msg', 'This title is already in use. Please choose a different title.');
|
||||||
|
return res.redirect(`/admin/pages/edit/${pageId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật thông tin trang
|
||||||
|
pages[pageIndex] = {
|
||||||
|
...page,
|
||||||
|
title,
|
||||||
|
slug: newSlug,
|
||||||
|
content,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lưu lại dữ liệu
|
||||||
|
contentData.pages = pages;
|
||||||
|
writeJsonFile('content', contentData);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Page updated successfully');
|
||||||
|
res.redirect('/admin/pages');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error updating page');
|
||||||
|
res.redirect(`/admin/pages/edit/${req.params.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xử lý xóa trang
|
||||||
|
exports.deletePage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pageId = req.params.id;
|
||||||
|
|
||||||
|
// Lấy dữ liệu hiện tại
|
||||||
|
const contentData = readJsonFile('content');
|
||||||
|
const pages = contentData.pages || [];
|
||||||
|
|
||||||
|
// Lọc bỏ trang cần xóa
|
||||||
|
contentData.pages = pages.filter(p => p.id !== pageId);
|
||||||
|
|
||||||
|
// Lưu lại dữ liệu
|
||||||
|
writeJsonFile('content', contentData);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Page deleted successfully');
|
||||||
|
res.redirect('/admin/pages');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error deleting page');
|
||||||
|
res.redirect('/admin/pages');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hiển thị trang theo slug
|
||||||
|
exports.getPageBySlug = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
|
||||||
|
// Lấy dữ liệu từ content.json
|
||||||
|
const contentData = readJsonFile('content');
|
||||||
|
const pages = contentData.pages || [];
|
||||||
|
|
||||||
|
// Tìm trang theo slug
|
||||||
|
const page = pages.find(p => p.slug === slug);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return res.status(404).render('page/not-found', {
|
||||||
|
title: 'Page Not Found',
|
||||||
|
message: 'The page you are looking for does not exist or has been deleted.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hiển thị trang
|
||||||
|
res.render('page/view', {
|
||||||
|
title: page.title,
|
||||||
|
page
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).render('page/error', {
|
||||||
|
title: 'Error',
|
||||||
|
message: 'An error occurred while loading the page. Please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
164
controllers/safetyController.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
const Safety = require("../models/safety");
|
||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
|
||||||
|
// Lấy dữ liệu Safety từ MongoDB
|
||||||
|
const getSafetyData = async () => {
|
||||||
|
const safety = await Safety.findOne().sort({ updatedAt: -1 });
|
||||||
|
if (!safety) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return safety.toObject();
|
||||||
|
};
|
||||||
|
|
||||||
|
// API endpoint cho frontend
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const safety = await getSafetyData();
|
||||||
|
if (!safety) {
|
||||||
|
return res.status(404).json({ error: "Safety data not found" });
|
||||||
|
}
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||||
|
const processedData = addBaseUrlToImages(safety, baseUrl);
|
||||||
|
res.json(processedData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Safety API error:", err);
|
||||||
|
res.status(500).json({ error: "Error loading safety data" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hiển thị danh sách Safety cho admin
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const items = await Safety.find().sort({ updatedAt: -1 }).limit(10);
|
||||||
|
// Lấy bản ghi mới nhất hoặc object rỗng nếu chưa có dữ liệu
|
||||||
|
const latest = items && items.length > 0 ? items[0] : null;
|
||||||
|
const data = latest ? (latest.toObject ? latest.toObject() : latest) : {
|
||||||
|
hero: { title: "", banner: "" },
|
||||||
|
approach: {},
|
||||||
|
approachImgs: [],
|
||||||
|
approachStats: [],
|
||||||
|
approachFeatures: [],
|
||||||
|
approachCards: [],
|
||||||
|
philosophy: {},
|
||||||
|
philosophyCards: [],
|
||||||
|
security: {},
|
||||||
|
securityCards: []
|
||||||
|
};
|
||||||
|
res.render("admin/safety/index", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Safety Management",
|
||||||
|
items,
|
||||||
|
data,
|
||||||
|
frontendUrl: process.env.FRONTEND_URL || req.protocol + "://" + req.get("host"),
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash("error_msg", "Error loading Safety data");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hiển thị form tạo mới Safety
|
||||||
|
exports.createForm = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.render("admin/safety/create", {
|
||||||
|
layout: "layouts/main",
|
||||||
|
title: "Create Safety",
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash("error_msg", "Error loading create form");
|
||||||
|
res.redirect("/admin/safety");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo mới Safety
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const safetyData = req.body; // Tùy chỉnh parse nếu cần
|
||||||
|
const newSafety = new Safety(safetyData);
|
||||||
|
await newSafety.save();
|
||||||
|
req.flash("success_msg", "Safety created successfully");
|
||||||
|
res.redirect("/admin/safety");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Create error:", err);
|
||||||
|
req.flash("error_msg", `Create error: ${err.message || "Unknown"}`);
|
||||||
|
res.redirect("/admin/safety/create");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật Safety
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, approach, philosophy, security } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroData = parseJson(hero);
|
||||||
|
const approachData = parseJson(approach);
|
||||||
|
const philosophyData = parseJson(philosophy);
|
||||||
|
const securityData = parseJson(security);
|
||||||
|
|
||||||
|
// Tìm hoặc tạo safety record
|
||||||
|
const items = await Safety.find().sort({ updatedAt: -1 }).limit(1);
|
||||||
|
let safety = items && items.length > 0 ? items[0] : null;
|
||||||
|
|
||||||
|
if (!safety) {
|
||||||
|
// Tạo mới
|
||||||
|
safety = new Safety({
|
||||||
|
hero: heroData || { title: "", banner: "" },
|
||||||
|
approach: approachData || {},
|
||||||
|
philosophy: philosophyData || {},
|
||||||
|
security: securityData || {}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Cập nhật
|
||||||
|
if (heroData) safety.hero = heroData;
|
||||||
|
if (approachData) safety.approach = approachData;
|
||||||
|
if (philosophyData) safety.philosophy = philosophyData;
|
||||||
|
if (securityData) safety.security = securityData;
|
||||||
|
}
|
||||||
|
|
||||||
|
await safety.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "Safety updated successfully");
|
||||||
|
res.redirect("/admin/safety");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Update error:", err);
|
||||||
|
req.flash("error_msg", `Update error: ${err.message || "Unknown"}`);
|
||||||
|
res.redirect("/admin/safety");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Xóa Safety
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const safety = await Safety.findById(req.params.id);
|
||||||
|
if (!safety) {
|
||||||
|
req.flash("error_msg", "Safety record not found");
|
||||||
|
return res.redirect("/admin/safety");
|
||||||
|
}
|
||||||
|
await Safety.findByIdAndDelete(req.params.id);
|
||||||
|
req.flash("success_msg", "Safety record deleted successfully");
|
||||||
|
res.redirect("/admin/safety");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete error:", err);
|
||||||
|
req.flash("error_msg", `Delete error: ${err.message || "Unknown"}`);
|
||||||
|
res.redirect("/admin/safety");
|
||||||
|
}
|
||||||
|
};
|
||||||
56
controllers/settingController.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const { readJsonFile, writeJsonFile } = require('../utils/jsonHelper');
|
||||||
|
|
||||||
|
// Hiển thị cài đặt
|
||||||
|
exports.getSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Lấy cài đặt từ file content.json
|
||||||
|
const content = readJsonFile('content');
|
||||||
|
const settings = content.settings || {
|
||||||
|
siteName: 'CMS-SIMS',
|
||||||
|
description: 'Hệ thống quản lý nội dung đơn giản'
|
||||||
|
};
|
||||||
|
|
||||||
|
res.render('admin/settings', {
|
||||||
|
title: 'Cài đặt hệ thống',
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error loading settings');
|
||||||
|
res.redirect('/admin/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật cài đặt
|
||||||
|
exports.updateSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { siteName, description } = req.body;
|
||||||
|
|
||||||
|
// Kiểm tra dữ liệu
|
||||||
|
if (!siteName) {
|
||||||
|
req.flash('error_msg', 'Website name cannot be empty');
|
||||||
|
return res.redirect('/admin/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy dữ liệu hiện tại
|
||||||
|
const content = readJsonFile('content');
|
||||||
|
|
||||||
|
// Cập nhật thông tin
|
||||||
|
content.settings = {
|
||||||
|
...content.settings,
|
||||||
|
siteName,
|
||||||
|
description,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lưu lại dữ liệu
|
||||||
|
writeJsonFile('content', content);
|
||||||
|
|
||||||
|
req.flash('success_msg', 'Settings updated successfully');
|
||||||
|
res.redirect('/admin/settings');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
req.flash('error_msg', 'Error updating settings');
|
||||||
|
res.redirect('/admin/settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
536
controllers/termsController.js
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
// controllers/termsController.js
|
||||||
|
const Terms = require("../models/terms");
|
||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper"); // Import helper
|
||||||
|
|
||||||
|
// API để lấy terms data (cho frontend)
|
||||||
|
exports.api = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.query.lang || "en";
|
||||||
|
|
||||||
|
// Sử dụng getDefault để đảm bảo luôn có data
|
||||||
|
const terms = await Terms.getDefault(language);
|
||||||
|
|
||||||
|
// Trả về data với cấu trúc mới
|
||||||
|
const termsData = terms.toObject();
|
||||||
|
|
||||||
|
// Sử dụng helper để thêm base URL vào đường dẫn ảnh
|
||||||
|
// Truyền baseUrl từ request hoặc từ environment
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(termsData, baseUrl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hero: processedData.hero,
|
||||||
|
page: processedData.page,
|
||||||
|
content: processedData.content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API Error:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading terms data",
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy toàn bộ terms data (cho admin)
|
||||||
|
exports.getTermsData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.query.lang || "en";
|
||||||
|
const terms = await Terms.findOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (!terms) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Terms data not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const termsData = terms.toObject();
|
||||||
|
|
||||||
|
// Thêm base URL vào đường dẫn ảnh
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(termsData, baseUrl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: processedData
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting terms data:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading terms data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để lấy data theo ngôn ngữ
|
||||||
|
exports.getByLanguage = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.params.lang || "en";
|
||||||
|
|
||||||
|
const terms = await Terms.findOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (!terms) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Terms data not found for language: " + language
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const termsData = terms.toObject();
|
||||||
|
|
||||||
|
// Thêm base URL vào đường dẫn ảnh
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedData = addBaseUrlToImages(termsData, baseUrl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hero: processedData.hero,
|
||||||
|
page: processedData.page,
|
||||||
|
content: processedData.content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting terms by language:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Error loading terms data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render admin view (không cần thêm baseUrl ở đây vì dùng trong CMS)
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Luôn đảm bảo có default data
|
||||||
|
const terms = await Terms.getDefault("en");
|
||||||
|
const data = terms.toObject();
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
|
||||||
|
res.render("admin/terms/index", {
|
||||||
|
title: "Terms & Conditions Management",
|
||||||
|
layout: "layouts/main",
|
||||||
|
data, // Không cần addBaseUrlToImages cho admin view
|
||||||
|
frontendUrl,
|
||||||
|
currentPath: req.path,
|
||||||
|
user: req.session.user
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in terms index:", error);
|
||||||
|
req.flash("error_msg", "An error occurred while loading the page");
|
||||||
|
res.redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cập nhật dữ liệu terms (CẬP NHẬT CẤU TRÚC MỚI)
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, page, content } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse all data với cấu trúc mới
|
||||||
|
const heroData = parseJson(hero) || {};
|
||||||
|
const pageData = parseJson(page) || {};
|
||||||
|
const contentData = parseJson(content) || {};
|
||||||
|
|
||||||
|
// Normalize embed blocks (convert YouTube watch URLs to /embed/ URLs)
|
||||||
|
function extractYouTubeId(url) {
|
||||||
|
if (!url || typeof url !== 'string') return null;
|
||||||
|
// common YouTube URL patterns
|
||||||
|
const m = url.match(/(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trong exports.update
|
||||||
|
if (contentData && Array.isArray(contentData.content)) {
|
||||||
|
contentData.content = contentData.content.map(item => {
|
||||||
|
if (item && item.type === 'embed') {
|
||||||
|
let embedUrl = item.embed || item.url || item.source || '';
|
||||||
|
|
||||||
|
// Luôn chuyển đổi sang embed URL nếu là watch URL
|
||||||
|
if (embedUrl.includes('youtube.com/watch')) {
|
||||||
|
const videoId = extractYouTubeId(embedUrl);
|
||||||
|
if (videoId) {
|
||||||
|
item.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
item.videoId = videoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Đảm bảo có videoId
|
||||||
|
else if (embedUrl && !item.videoId) {
|
||||||
|
const videoId = extractYouTubeId(embedUrl);
|
||||||
|
if (videoId) {
|
||||||
|
item.videoId = videoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tìm hoặc tạo terms
|
||||||
|
let terms = await Terms.findOne({ name: "default", language: "en" });
|
||||||
|
|
||||||
|
if (!terms) {
|
||||||
|
// Tạo mới với cấu trúc mới
|
||||||
|
terms = new Terms({
|
||||||
|
name: "default",
|
||||||
|
language: "en",
|
||||||
|
hero: heroData,
|
||||||
|
page: pageData,
|
||||||
|
content: contentData,
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
migratedFromOldStructure: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update existing với cấu trúc mới
|
||||||
|
terms.hero = heroData;
|
||||||
|
terms.page = pageData;
|
||||||
|
terms.content = contentData;
|
||||||
|
terms.version = "2.0.0";
|
||||||
|
terms.migratedFromOldStructure = false;
|
||||||
|
terms.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
await terms.save();
|
||||||
|
|
||||||
|
req.flash("success_msg", "Terms & Conditions updated successfully");
|
||||||
|
res.redirect("/admin/terms-conditions");
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error updating terms:", err);
|
||||||
|
req.flash("error_msg", err.message || "Error updating terms");
|
||||||
|
res.redirect("/admin/terms-conditions");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed data từ JSON file mới (cấu trúc mới)
|
||||||
|
exports.seed = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Đọc file JSON
|
||||||
|
const jsonPath = path.join(__dirname, '../data/terms-conditions.json');
|
||||||
|
const jsonData = JSON.parse(await fs.readFile(jsonPath, 'utf8'));
|
||||||
|
|
||||||
|
console.log('Seeding from JSON...');
|
||||||
|
console.log('JSON structure keys:', Object.keys(jsonData));
|
||||||
|
|
||||||
|
// Kiểm tra cấu trúc JSON
|
||||||
|
let terms;
|
||||||
|
if (jsonData.hero && jsonData.page && jsonData.content) {
|
||||||
|
// Cấu trúc mới
|
||||||
|
console.log('Using new structure (hero, page, content)');
|
||||||
|
terms = await Terms.migrateFromNewJson(jsonData, "en");
|
||||||
|
} else if (jsonData.hero && jsonData.termsHeader && jsonData.sections) {
|
||||||
|
// Cấu trúc cũ
|
||||||
|
console.log('Using old structure, converting to new...');
|
||||||
|
terms = await Terms.migrateFromJson(jsonData, "en");
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown JSON structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Terms data seeded successfully",
|
||||||
|
data: {
|
||||||
|
id: terms._id,
|
||||||
|
hero: terms.hero,
|
||||||
|
page: terms.page,
|
||||||
|
content: terms.content
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding terms:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Error seeding terms data"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API preview cho admin (tạo HTML preview)
|
||||||
|
exports.preview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, page, content } = req.body;
|
||||||
|
|
||||||
|
// Parse JSON strings
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroData = parseJson(hero) || {};
|
||||||
|
const pageData = parseJson(page) || {};
|
||||||
|
const contentData = parseJson(content) || {};
|
||||||
|
|
||||||
|
// Thêm base URL vào đường dẫn ảnh cho preview
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processedHeroData = addBaseUrlToImages(heroData, baseUrl);
|
||||||
|
|
||||||
|
// Render preview HTML
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${pageData.title || 'Terms & Conditions Preview'}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; }
|
||||||
|
.hero-section {
|
||||||
|
background: linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)),
|
||||||
|
url('${processedHeroData.backgroundImage || ''}');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
color: white;
|
||||||
|
padding: 100px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.content-section {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.content-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="hero-section">
|
||||||
|
<h1>${heroData.title || 'Terms & Conditions'}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="container">
|
||||||
|
<h2>${pageData.title || 'Terms & Conditions'}</h2>
|
||||||
|
${pageData.divider !== false ? '<hr>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section -->
|
||||||
|
<div class="content-section">
|
||||||
|
<div class="container">
|
||||||
|
${renderContentItems(contentData.content || [])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.send(html);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating preview:", error);
|
||||||
|
res.status(500).send("Error generating preview");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function để render content items
|
||||||
|
function renderContentItems(contentItems) {
|
||||||
|
if (!Array.isArray(contentItems) || contentItems.length === 0) {
|
||||||
|
return '<p>No content available.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentItems.map(item => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'paragraph':
|
||||||
|
return `<div class="content-item"><p>${item.text || ''}</p></div>`;
|
||||||
|
|
||||||
|
case 'section':
|
||||||
|
let html = `<div class="content-item">`;
|
||||||
|
html += `<h3>${item.title || ''}</h3>`;
|
||||||
|
html += `<p>${item.content || ''}</p>`;
|
||||||
|
|
||||||
|
if (item.subsections && item.subsections.length > 0) {
|
||||||
|
item.subsections.forEach(subsection => {
|
||||||
|
if (subsection.type === 'cancellation_table') {
|
||||||
|
html += `<h4>${subsection.title || ''}</h4>`;
|
||||||
|
if (subsection.items && subsection.items.length > 0) {
|
||||||
|
html += '<ul>';
|
||||||
|
subsection.items.forEach(listItem => {
|
||||||
|
html += `<li>${listItem}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
} else if (subsection.type === 'cancellation_section') {
|
||||||
|
html += `<h4>${subsection.title || ''}</h4>`;
|
||||||
|
if (subsection.items && subsection.items.length > 0) {
|
||||||
|
html += '<ul>';
|
||||||
|
subsection.items.forEach(listItem => {
|
||||||
|
html += `<li>${listItem}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
} else if (subsection.type === 'note') {
|
||||||
|
html += `<div class="alert alert-info">${subsection.text || ''}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
return html;
|
||||||
|
|
||||||
|
case 'note':
|
||||||
|
return `<div class="content-item alert alert-info">${item.text || ''}</div>`;
|
||||||
|
case 'embed':
|
||||||
|
// Support several embed shapes: { embed }, { url }, { source }, { videoId }
|
||||||
|
const embedSrc = (item.embed || item.url || item.source || (item.videoId ? `https://www.youtube.com/embed/${item.videoId}` : ''));
|
||||||
|
if (!embedSrc) return `<div class="content-item">Invalid embed</div>`;
|
||||||
|
return `<div class="content-item embed-item" style="margin-bottom:20px;">
|
||||||
|
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
|
||||||
|
<iframe src="${embedSrc}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen loading="lazy" referrerpolicy="no-referrer"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `<div class="content-item">Unknown content type: ${item.type}</div>`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// API để tạo terms mới (cho các ngôn ngữ khác)
|
||||||
|
exports.create = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hero, page, content, language } = req.body;
|
||||||
|
|
||||||
|
if (!language) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Language is required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra đã tồn tại chưa
|
||||||
|
const existing = await Terms.findOne({ name: "default", language: language });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Terms already exists for language: " + language
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON nếu cần
|
||||||
|
const parseJson = (data) => {
|
||||||
|
if (!data) return null;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("JSON parse error:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const terms = new Terms({
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
hero: parseJson(hero) || {},
|
||||||
|
page: parseJson(page) || {},
|
||||||
|
content: parseJson(content) || {},
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
migratedFromOldStructure: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await terms.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Terms created successfully for language: " + language,
|
||||||
|
data: terms
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating terms:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Error creating terms"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API để xóa terms (theo ngôn ngữ)
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const language = req.params.lang;
|
||||||
|
|
||||||
|
if (!language) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Language is required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Không cho phép xóa tiếng Anh mặc định
|
||||||
|
if (language === "en") {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Cannot delete default English terms"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Terms.deleteOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Terms not found for language: " + language
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Terms deleted successfully for language: " + language
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting terms:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || "Error deleting terms"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
232
controllers/travelController.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
const Travel = require("../models/travel");
|
||||||
|
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hàm Helper: Trích xuất ID YouTube từ nhiều định dạng link khác nhau
|
||||||
|
*/
|
||||||
|
function extractYouTubeId(url) {
|
||||||
|
if (!url || typeof url !== 'string') return null;
|
||||||
|
// Hỗ trợ: watch?v=, embed/, youtu.be/, shorts/
|
||||||
|
const regex = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?v=|embed\/|v\/|shorts\/))([A-Za-z0-9_-]{11})/;
|
||||||
|
const match = url.match(regex);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hàm Helper: Làm sạch danh sách blocks của Editor.js
|
||||||
|
* Loại bỏ duplicate video (vừa embed vừa text link) và paragraph rỗng
|
||||||
|
*/
|
||||||
|
function sanitizeContentBlocks(blocks) {
|
||||||
|
if (!blocks || !Array.isArray(blocks)) return [];
|
||||||
|
|
||||||
|
const seenVideoIds = new Set();
|
||||||
|
|
||||||
|
// Bước 1: Duyệt qua để chuẩn hóa block embed và lấy danh sách ID video
|
||||||
|
const processedBlocks = blocks.map(block => {
|
||||||
|
if (block.type === 'embed') {
|
||||||
|
const url = block.data.source || block.data.embed || '';
|
||||||
|
const videoId = extractYouTubeId(url);
|
||||||
|
if (videoId) {
|
||||||
|
seenVideoIds.add(videoId);
|
||||||
|
// Cập nhật lại data chuẩn cho Editor.js
|
||||||
|
block.data.embed = `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
block.data.source = url;
|
||||||
|
block.data.videoId = videoId;
|
||||||
|
block.data.service = 'youtube';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return block;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bước 2: Lọc bỏ paragraph rác
|
||||||
|
return processedBlocks.filter(block => {
|
||||||
|
if (block.type === 'paragraph') {
|
||||||
|
const text = (block.data?.text || '').trim();
|
||||||
|
|
||||||
|
// Xóa paragraph rỗng
|
||||||
|
if (text === '' || text === '<br>' || text === ' ') return false;
|
||||||
|
|
||||||
|
// Xóa paragraph nếu nó chỉ chứa 1 link YouTube đã được embed ở trên
|
||||||
|
const videoIdInText = extractYouTubeId(text);
|
||||||
|
if (videoIdInText && seenVideoIds.has(videoIdInText)) {
|
||||||
|
console.log(`[Sanitizer] Removed duplicate text link for video: ${videoIdInText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: Show travel editor
|
||||||
|
exports.index = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const travel = await Travel.findOne();
|
||||||
|
|
||||||
|
if (!travel) {
|
||||||
|
return res.render("admin/travel/index", {
|
||||||
|
title: "Travel Management",
|
||||||
|
data: {
|
||||||
|
page: { title: "Travel Information", description: "", metadata: { title: "", description: "" } },
|
||||||
|
hero: { title: "Travel Information", backgroundImage: "" },
|
||||||
|
content: { blocks: [] },
|
||||||
|
enableScrollspy: false,
|
||||||
|
},
|
||||||
|
message: "No travel data found. Please run migration first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render("admin/travel/index", {
|
||||||
|
title: "Travel Management",
|
||||||
|
data: travel,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading travel page:", error);
|
||||||
|
res.status(500).send("Error loading travel page");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST: Update travel information
|
||||||
|
exports.update = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { page, hero, content, enableScrollspy } = req.body;
|
||||||
|
const updateData = {};
|
||||||
|
|
||||||
|
if (page) updateData.page = JSON.parse(page);
|
||||||
|
if (hero) updateData.hero = JSON.parse(hero);
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
let contentObj = JSON.parse(content);
|
||||||
|
// Áp dụng bộ lọc dọn dẹp nội dung
|
||||||
|
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||||
|
updateData.content = contentObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableScrollspy !== undefined) {
|
||||||
|
updateData.enableScrollspy = (enableScrollspy === "true" || enableScrollspy === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Travel.findOneAndUpdate({}, updateData, {
|
||||||
|
upsert: true,
|
||||||
|
new: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
req.flash("success", "Travel information updated and sanitized successfully");
|
||||||
|
res.redirect("/admin/travel");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating travel:", error);
|
||||||
|
req.flash("error", "Error updating travel information");
|
||||||
|
res.redirect("/admin/travel");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET: Travel data API (Sử dụng cho Frontend/Public)
|
||||||
|
exports.api = exports.getTravelData = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const travel = await Travel.findOne();
|
||||||
|
if (!travel) {
|
||||||
|
return res.status(404).json({ error: "Travel data not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const travelObj = travel.toObject();
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const processed = addBaseUrlToImages(travelObj, baseUrl);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
hero: processed.hero,
|
||||||
|
page: processed.page,
|
||||||
|
content: processed.content,
|
||||||
|
enableScrollspy: processed.enableScrollspy
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching travel data:", error);
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST: Preview travel
|
||||||
|
exports.preview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, pageTitle, heroTitle, heroBackgroundImage, pageYear } = req.body;
|
||||||
|
|
||||||
|
// Preview cũng cần được sanitize để hiển thị đúng thực tế khi lưu
|
||||||
|
let contentObj = JSON.parse(content);
|
||||||
|
contentObj.blocks = sanitizeContentBlocks(contentObj.blocks);
|
||||||
|
|
||||||
|
const previewData = {
|
||||||
|
page: {
|
||||||
|
title: pageTitle || "Travel Information",
|
||||||
|
year: pageYear || ""
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
title: heroTitle || "Travel Information",
|
||||||
|
backgroundImage: heroBackgroundImage || "",
|
||||||
|
},
|
||||||
|
content: contentObj,
|
||||||
|
enableScrollspy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.render("page/travel", {
|
||||||
|
title: "Travel Preview",
|
||||||
|
data: previewData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating preview:", error);
|
||||||
|
res.status(500).send("Error generating preview");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET: Seed/Import from JSON
|
||||||
|
exports.seed = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const jsonPath = path.join(__dirname, "../data/travel.json");
|
||||||
|
const jsonData = await fs.readFile(jsonPath, "utf-8");
|
||||||
|
const jsonTravelData = JSON.parse(jsonData);
|
||||||
|
|
||||||
|
let contentBlocks = [];
|
||||||
|
|
||||||
|
// Trường hợp JSON đã có định dạng bài viết (blog format)
|
||||||
|
if (Array.isArray(jsonTravelData.posts) && jsonTravelData.posts.length > 0) {
|
||||||
|
const firstPost = jsonTravelData.posts[0];
|
||||||
|
contentBlocks = (firstPost.content && firstPost.content.blocks) ? firstPost.content.blocks : [];
|
||||||
|
}
|
||||||
|
// Trường hợp format cũ (legacy)
|
||||||
|
else {
|
||||||
|
// ... (Logic chuyển đổi legacy format giữ nguyên nhưng bọc qua sanitize)
|
||||||
|
// Ví dụ: push các header, paragraph từ locations vào contentBlocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Luôn làm sạch dữ liệu trước khi seed vào DB
|
||||||
|
const cleanedBlocks = sanitizeContentBlocks(contentBlocks);
|
||||||
|
|
||||||
|
const travelData = {
|
||||||
|
page: {
|
||||||
|
title: jsonTravelData.page?.title || "Go and Grow Camp Travel Information",
|
||||||
|
year: jsonTravelData.page?.year || "",
|
||||||
|
metadata: {
|
||||||
|
title: "Travel Guide - Go and Grow Camp",
|
||||||
|
description: "Everything you need to know about traveling to our camps",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
title: jsonTravelData.hero?.title || "Travel Information",
|
||||||
|
backgroundImage: jsonTravelData.hero?.backgroundImage || "",
|
||||||
|
},
|
||||||
|
content: { blocks: cleanedBlocks },
|
||||||
|
enableScrollspy: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Travel.findOneAndUpdate({}, travelData, { upsert: true, new: true });
|
||||||
|
|
||||||
|
req.flash("success", "Travel data seeded and sanitized successfully");
|
||||||
|
res.redirect("/admin/travel");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding travel data:", error);
|
||||||
|
req.flash("error", "Failed to seed travel data");
|
||||||
|
res.redirect("/admin/travel");
|
||||||
|
}
|
||||||
|
};
|
||||||
228
controllers/uploadController.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const jsonHelper = require('../utils/jsonHelper');
|
||||||
|
|
||||||
|
// Controller xử lý upload ảnh
|
||||||
|
const uploadController = {
|
||||||
|
// Upload ảnh và trả về đường dẫn
|
||||||
|
uploadImage: async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ success: false, error: 'No file was uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy loại ảnh từ query params
|
||||||
|
const imageType = req.query.imageType || 'general';
|
||||||
|
|
||||||
|
// Tạo đường dẫn tương đối để lưu vào database
|
||||||
|
const relativePath = `/uploads/${imageType}/${req.file.filename}`;
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const fullUrl = `${baseUrl}${relativePath}`;
|
||||||
|
|
||||||
|
// Kiểm tra nếu file đã tồn tại từ trước
|
||||||
|
const fileAlreadyExists = req.fileAlreadyExists || false;
|
||||||
|
|
||||||
|
// Nếu client yêu cầu cập nhật trực tiếp file JSON (ví dụ activities.json),
|
||||||
|
// thì đồng bộ camps.image và camps.camp-detail.hero.bgImage
|
||||||
|
try {
|
||||||
|
const jsonFile = req.body && req.body.jsonFile;
|
||||||
|
const campLink = req.body && req.body.campLink;
|
||||||
|
|
||||||
|
if (jsonFile && campLink) {
|
||||||
|
// Đọc JSON và cập nhật camp có link khớp
|
||||||
|
const jsonFilePath = require('path').join(__dirname, '../data', jsonFile);
|
||||||
|
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
|
||||||
|
|
||||||
|
if (jsonData && Array.isArray(jsonData.camps)) {
|
||||||
|
// campLink có thể được gửi không có dấu / đầu, chuẩn hóa
|
||||||
|
const normalizedLink = campLink.startsWith('/') ? campLink : `/${campLink}`;
|
||||||
|
|
||||||
|
const camp = jsonData.camps.find(c => (c.link || '').toString() === normalizedLink || (c.link || '').toString() === campLink);
|
||||||
|
if (camp) {
|
||||||
|
// Đồng bộ camps.image và camps.camp-detail.hero.bgImage - 2 trường này luôn giống nhau
|
||||||
|
camp.image = relativePath;
|
||||||
|
|
||||||
|
// Đảm bảo camp-detail.hero tồn tại và sync bgImage
|
||||||
|
if (!camp['camp-detail']) camp['camp-detail'] = {};
|
||||||
|
if (!camp['camp-detail'].hero) camp['camp-detail'].hero = {};
|
||||||
|
camp['camp-detail'].hero.bgImage = relativePath;
|
||||||
|
|
||||||
|
// Lưu thay đổi
|
||||||
|
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update JSON file after upload:', e && e.message ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
path: relativePath,
|
||||||
|
url: fullUrl,
|
||||||
|
reused: fileAlreadyExists,
|
||||||
|
message: fileAlreadyExists ? 'Using existing file' : 'File uploaded successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading image:', error);
|
||||||
|
return res.status(500).json({ success: false, error: 'Server error while uploading image' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cập nhật đường dẫn ảnh trong file JSON
|
||||||
|
updateImagePath: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { jsonFile, jsonPath, newImagePath } = req.body;
|
||||||
|
|
||||||
|
if (!jsonFile || !jsonPath || !newImagePath) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Missing required information (jsonFile, jsonPath, newImagePath)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đọc file JSON
|
||||||
|
const jsonFilePath = path.join(__dirname, '../data', jsonFile);
|
||||||
|
const jsonData = jsonHelper.readJsonFile(jsonFilePath);
|
||||||
|
|
||||||
|
// Cập nhật đường dẫn ảnh theo jsonPath
|
||||||
|
// jsonPath có định dạng như "banner.image" hoặc "partners[0].logo"
|
||||||
|
const pathParts = jsonPath.split('.');
|
||||||
|
let current = jsonData;
|
||||||
|
|
||||||
|
// Duyệt qua các phần của path trừ phần cuối
|
||||||
|
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||||
|
const part = pathParts[i];
|
||||||
|
|
||||||
|
// Kiểm tra nếu là mảng (ví dụ: partners[0])
|
||||||
|
if (part.includes('[') && part.includes(']')) {
|
||||||
|
const arrName = part.substring(0, part.indexOf('['));
|
||||||
|
const index = parseInt(part.substring(part.indexOf('[') + 1, part.indexOf(']')));
|
||||||
|
|
||||||
|
if (!current[arrName] || !Array.isArray(current[arrName])) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Array ${arrName} not found in data`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[arrName][index];
|
||||||
|
} else {
|
||||||
|
if (!current[part]) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Property ${part} not found in data`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật giá trị
|
||||||
|
const lastPart = pathParts[pathParts.length - 1];
|
||||||
|
current[lastPart] = newImagePath;
|
||||||
|
|
||||||
|
// Lưu lại file JSON
|
||||||
|
jsonHelper.writeJsonFile(jsonFilePath, jsonData);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Image path updated successfully',
|
||||||
|
data: { jsonPath, newImagePath }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating image path:', error);
|
||||||
|
return res.status(500).json({ success: false, message: 'Server error while updating image path' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Xóa ảnh
|
||||||
|
deleteImage: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { imagePath } = req.body;
|
||||||
|
|
||||||
|
if (!imagePath) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Missing image path to delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chuyển đổi đường dẫn tương đối thành đường dẫn tuyệt đối
|
||||||
|
const fullPath = path.join(__dirname, '../public', imagePath);
|
||||||
|
|
||||||
|
// Kiểm tra xem file có tồn tại không
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Image file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xóa file
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Image deleted successfully',
|
||||||
|
data: { imagePath }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting image:', error);
|
||||||
|
return res.status(500).json({ success: false, message: 'Server error while deleting image' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// List images in a folder
|
||||||
|
listImages: async (req, res) => {
|
||||||
|
try {
|
||||||
|
const imageType = req.query.imageType || 'general';
|
||||||
|
const dirPath = path.join(__dirname, '../public/uploads', imageType);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
return res.status(200).json({ success: true, images: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(dirPath).filter(f => !f.startsWith('.'));
|
||||||
|
|
||||||
|
const images = files.map(name => ({
|
||||||
|
name,
|
||||||
|
path: `/uploads/${imageType}/${name}`,
|
||||||
|
url: (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`) + `/uploads/${imageType}/${name}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, images });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error listing images:', error);
|
||||||
|
return res.status(500).json({ success: false, error: 'Server error while listing images' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload video
|
||||||
|
uploadVideo: async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ success: false, error: 'No file was uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra loại file
|
||||||
|
const fileType = req.file.mimetype;
|
||||||
|
if (!fileType.startsWith('video/')) {
|
||||||
|
// Xóa file nếu không phải video
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
return res.status(400).json({ success: false, error: 'Uploaded file is not a video' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo đường dẫn tương đối để lưu vào database
|
||||||
|
const relativePath = `/uploads/videos/${req.file.filename}`;
|
||||||
|
const baseUrl = process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`;
|
||||||
|
const fullUrl = `${baseUrl}${relativePath}`;
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
path: relativePath,
|
||||||
|
url: fullUrl
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading video:', error);
|
||||||
|
return res.status(500).json({ success: false, error: 'Server error while uploading video' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = uploadController;
|
||||||
114
data/Countries.json
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
{
|
||||||
|
"countries": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "France",
|
||||||
|
"icon": "assets/img/home-2/visa/03.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "UK",
|
||||||
|
"icon": "assets/img/home-2/visa/11.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Canada",
|
||||||
|
"icon": "assets/img/home-2/visa/02.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Germany",
|
||||||
|
"icon": "assets/img/home-2/visa/12.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "Spain",
|
||||||
|
"icon": "assets/img/home-2/visa/13.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "South Korea",
|
||||||
|
"icon": "assets/img/home-2/visa/14.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Japan",
|
||||||
|
"icon": "assets/img/home-2/visa/15.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "Croatia",
|
||||||
|
"icon": "assets/img/home-2/visa/16.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "England",
|
||||||
|
"icon": "assets/img/home-2/visa/17.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "Indonesia",
|
||||||
|
"icon": "assets/img/home-2/visa/18.png",
|
||||||
|
"services": [
|
||||||
|
"Student Visa & Admission",
|
||||||
|
"Work Visa – H1B",
|
||||||
|
"Work permit for Canada",
|
||||||
|
"Student Visa for Canada"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
146
data/Countrydetails.json
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"countryDetails": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "United States of America",
|
||||||
|
"title": "COUNTRY USA",
|
||||||
|
"mainImage": "assets/img/inner-page/country-details/details-1.jpg",
|
||||||
|
"description": "The United States is one of the most popular destinations for international students and immigrants, offering world-class universities, diverse cultural experiences, and countless career opportunities. With top-ranked education systems, advanced research facilities, and a welcoming environment for skilled professionals, the USA is ideal for those seeking growth and global exposure.",
|
||||||
|
"additionalInfo": "Our consultancy provides complete guidance for study visas, work permits, and permanent residency pathways tailored to your goals.",
|
||||||
|
"tagline": "Over the last 35 Years we made an impact that is strong & we have long way to go.",
|
||||||
|
"visaTypes": [
|
||||||
|
{
|
||||||
|
"category": "Tourist & Work",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Tourist Visa",
|
||||||
|
"description": "Broad term that can refer to various aspects of interconnectedness"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Work Permit",
|
||||||
|
"description": "Broad term that can refer to various aspects of interconnectedness"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "Student & Family",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Student",
|
||||||
|
"description": "Broad term that can refer to various aspects of interconnectedness"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tourist Visa",
|
||||||
|
"description": "Broad term that can refer to various aspects of interconnectedness"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visaProcess": [
|
||||||
|
{
|
||||||
|
"number": "01",
|
||||||
|
"title": "Consultation & Eligibility Check",
|
||||||
|
"description": "Our experts review your profile and visa requirements."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "02",
|
||||||
|
"title": "Application Preparation",
|
||||||
|
"description": "We help with document collection, form filling, and statement drafting."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "03",
|
||||||
|
"title": "Submission",
|
||||||
|
"description": "Visa application is submitted online with required fees."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "04",
|
||||||
|
"title": "Interview Guidance",
|
||||||
|
"description": "Get training and mock sessions for embassy interview."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "05",
|
||||||
|
"title": "Approval & Travel",
|
||||||
|
"description": "Once approved, we provide travel and pre-departure guidance."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
"assets/img/inner-page/country-details/details-2.jpg",
|
||||||
|
"assets/img/inner-page/country-details/details-3.png"
|
||||||
|
],
|
||||||
|
"visaCategories": [
|
||||||
|
"Student Visa (F1, M1, J1)",
|
||||||
|
"Work Visa (H1B, L1)",
|
||||||
|
"Tourist Visa (B1/B2)",
|
||||||
|
"Family/Spouse Visa (K1, IR1, F2A)",
|
||||||
|
"Green Card / Immigrant Visa"
|
||||||
|
],
|
||||||
|
"serviceOptions": [
|
||||||
|
{
|
||||||
|
"number": "01",
|
||||||
|
"title": "Consultation & Eligibility Check",
|
||||||
|
"description": "Our experts review your profile and visa requirements."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "02",
|
||||||
|
"title": "Application Preparation",
|
||||||
|
"description": "We help with document collection, form filling, and statement drafting."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "03",
|
||||||
|
"title": "Submission",
|
||||||
|
"description": "Visa application is submitted online with required fees."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "04",
|
||||||
|
"title": "Interview Guidance",
|
||||||
|
"description": "Get training and mock sessions for embassy interview."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "05",
|
||||||
|
"title": "Approval & Travel",
|
||||||
|
"description": "Once approved, we provide travel and pre-departure guidance."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"relatedCountries": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Canada",
|
||||||
|
"icon": "assets/img/inner-page/country-details/01.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "USA",
|
||||||
|
"icon": "assets/img/inner-page/country-details/02.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "USA",
|
||||||
|
"icon": "assets/img/inner-page/country-details/03.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Saint Helena",
|
||||||
|
"icon": "assets/img/inner-page/country-details/05.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "Iran",
|
||||||
|
"icon": "assets/img/inner-page/country-details/06.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "Spain",
|
||||||
|
"icon": "assets/img/inner-page/country-details/07.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Japan",
|
||||||
|
"icon": "assets/img/inner-page/country-details/08.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contactInfo": {
|
||||||
|
"phone": "+009 438 222 9540",
|
||||||
|
"email": "infor@xridergamil.com",
|
||||||
|
"location": "Toronto, Montreal, City 2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
data/aboutCamp.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"avatars": [
|
||||||
|
"yootheme/aboutImage/profile-face_1.jpg",
|
||||||
|
"yootheme/aboutImage/young-tourist-sitting-tent.jpg",
|
||||||
|
"yootheme/aboutImage/portrait-young-male-tourist-standing-forest-with-tent.jpg"
|
||||||
|
],
|
||||||
|
"images": {
|
||||||
|
"mainImage1": "yootheme/img/a1.jpg",
|
||||||
|
"mainImage2": "yootheme/img/a2.jpg"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"sectionTitle": "About Us",
|
||||||
|
"mainTitle": "Creating Amazing Camps",
|
||||||
|
"description": "Learning is closely tied to practical experience—summer is the perfect time for hands-on opportunities. While knowledge must still be nurtured, it can take on new and more engaging forms.",
|
||||||
|
"quote": "Your Journey, Your Comfort,\nYour Adventure.",
|
||||||
|
"authorText": "Adventurer with\nhappy customer",
|
||||||
|
"targetCount": 50
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
"Fun-Filled Experiences for Every Camper",
|
||||||
|
"Adventures That Inspire Confidence and Growth",
|
||||||
|
"Memories and Friendships That Last a Lifetime"
|
||||||
|
],
|
||||||
|
"button": {
|
||||||
|
"label": "Learn More About",
|
||||||
|
"href": "/info/about"
|
||||||
|
}
|
||||||
|
}
|
||||||
149
data/aboutUs.json
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "About Us",
|
||||||
|
"backgroundImage": "/uploads/about/banner.jpg"
|
||||||
|
},
|
||||||
|
|
||||||
|
"introduction": {
|
||||||
|
"subtitle": "Go & Grow Camp",
|
||||||
|
"title": "Go & Grow Camp A Place to Learn, Connect, and Grow",
|
||||||
|
"description": "Go & Grow Camp brings together young people from different countries and cultures to enjoy fun activities, meaningful projects, and positive community experiences. Every camper—new or returning—quickly feels included thanks to our welcoming environment and supportive team.",
|
||||||
|
"mainImage": "/uploads/about/section2.jpg",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"title": "Always Here",
|
||||||
|
"description": "Camp leaders are ready to guide and support you whenever needed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fun & Learning",
|
||||||
|
"description": "Engage in exciting activities that help you grow new skills."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Team Spirit",
|
||||||
|
"description": "Work together, take responsibility, and support each other at camp."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"statistics": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"number": "2K+",
|
||||||
|
"description": "Smiles and Friendships Made"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "25+",
|
||||||
|
"description": "Countries Connected"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "50+",
|
||||||
|
"description": "Adventure & Skill-Building Activities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": "20+",
|
||||||
|
"description": "Exciting Challenges Every Camp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"accommodation": {
|
||||||
|
"subtitle": "Accommodation",
|
||||||
|
"title": "Blending Comfort With Responsible Living",
|
||||||
|
"description": "Enjoy a tranquil atmosphere with beautiful views, modern facilities, and personal touches that make you feel at home.",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"title": "Safe Environment",
|
||||||
|
"description": "Safety is our top priority, with secure facilities and connecting with nature.",
|
||||||
|
"icon": "/uploads/about/act2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Family Atmosphere",
|
||||||
|
"description": "Every camper is part of our big camp family, where friendships grow and everyone feels included.",
|
||||||
|
"icon": "/uploads/about/act2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cultural Exchange",
|
||||||
|
"description": "Experience diversity and learn about different cultures from campers around the world.",
|
||||||
|
"icon": "/uploads/about/act2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Personal Growth",
|
||||||
|
"description": "Activities encourage confidence, independence, and learning through fun challenges.",
|
||||||
|
"icon": "/uploads/about/act2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Creativity & Fun",
|
||||||
|
"description": "Express yourself through games, arts, and exciting hands-on experiences.",
|
||||||
|
"icon": "/uploads/about/act2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Creativity & Fun",
|
||||||
|
"description": "Express yourself through games, arts, and exciting hands-on experiences.",
|
||||||
|
"icon": "/uploads/about/act2.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"activities": {
|
||||||
|
"subtitle": "Activities",
|
||||||
|
"title": "Enjoy unforgettable experiences at Go and Grow Camp",
|
||||||
|
"description": "Discover a world of adventure, creativity, and friendship. From exciting outdoor activities to hands-on workshops, every day is full of new experiences that help campers grow, have fun, and make memories that last a lifetime.",
|
||||||
|
"gallery": [
|
||||||
|
{
|
||||||
|
"image": "/uploads/about/act1.jpg",
|
||||||
|
"title": "Outdoor Adventures",
|
||||||
|
"description": "Climb, paddle and explore with our experienced team."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/uploads/about/act2.jpg",
|
||||||
|
"title": "Creative Workshops",
|
||||||
|
"description": "Arts & crafts sessions to spark imagination."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/uploads/about/act3.jpg",
|
||||||
|
"title": "Water Sports",
|
||||||
|
"description": "Safe swimming and supervised water activities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image": "/uploads/about/act4.jpg",
|
||||||
|
"title": "Campfire Nights",
|
||||||
|
"description": "Evening stories, music, and marshmallow roasting."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"newsletter": {
|
||||||
|
"imagePath": "/uploads/about/newsletter.jpg",
|
||||||
|
"title": "Stay Updated with Our Monthly",
|
||||||
|
"description": "Sign up to receive the latest news about new camps, activities, and exciting opportunities. Don't miss out on anything fun!",
|
||||||
|
"buttonText": "Subscribe"
|
||||||
|
},
|
||||||
|
|
||||||
|
"events": {
|
||||||
|
"title": "Tour Events for you",
|
||||||
|
"items": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"imageUrl": "/uploads/about/act1.jpg",
|
||||||
|
"date": "September 19, 2022",
|
||||||
|
"title": "The Bottom Line on Dietary Supplements",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sit amet tristique neque, in suscipit sem. Fusce ut tellus neque. Suspendisse ...",
|
||||||
|
"age": "Age Group: 10–14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imageUrl": "/uploads/about/act2.jpg",
|
||||||
|
"date": "September 19, 2022",
|
||||||
|
"title": "The Bottom Line on Dietary Supplements",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sit amet tristique neque, in suscipit sem. Fusce ut tellus neque. Suspendisse ...",
|
||||||
|
"age": "Age Group: 10–14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"imageUrl": "/uploads/about/act3.jpg",
|
||||||
|
"date": "September 19, 2022",
|
||||||
|
"title": "The Bottom Line on Dietary Supplements",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris sit amet tristique neque, in suscipit sem. Fusce ut tellus neque. Suspendisse ...",
|
||||||
|
"age": "Age Group: 10–14"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6762
data/activities.json
Normal file
690
data/booking.json
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Booking",
|
||||||
|
"backgroundImage": "/uploads/booking/b13.jpg"
|
||||||
|
},
|
||||||
|
"searchBar": {
|
||||||
|
"locationLabel": "Location",
|
||||||
|
"holidaySeasonLabel": "Holiday Season",
|
||||||
|
"searchButtonText": "Search"
|
||||||
|
},
|
||||||
|
"filterPanel": {
|
||||||
|
"title": "FIND YOUR CAMP!",
|
||||||
|
"priceTitle": "Price",
|
||||||
|
"priceLabel": "Maximum Price (USD)",
|
||||||
|
"pricePlaceholder": "Enter max price",
|
||||||
|
"priceMin": 0,
|
||||||
|
"priceMax": 2000,
|
||||||
|
"activitiesTitle": "Activities",
|
||||||
|
"ageTitle": "AGE",
|
||||||
|
"ageSelectPlaceholder": "Select age",
|
||||||
|
"ageMin": 7,
|
||||||
|
"ageMax": 18,
|
||||||
|
"ratingTitle": "RATING WISE",
|
||||||
|
"ratingOptions": [
|
||||||
|
{ "value": "", "label": "All Ratings" },
|
||||||
|
{ "value": "5", "label": "5 Stars" },
|
||||||
|
{ "value": "4", "label": "4 Stars & Up" },
|
||||||
|
{ "value": "3", "label": "3 Stars & Up" },
|
||||||
|
{ "value": "2", "label": "2 Stars & Up" },
|
||||||
|
{ "value": "1", "label": "1 Star & Up" }
|
||||||
|
],
|
||||||
|
"resetButtonText": "Reset"
|
||||||
|
},
|
||||||
|
"programs": [
|
||||||
|
{ "value": "adventure", "label": "Adventure, Sports & Creative" },
|
||||||
|
{ "value": "arts-crafts", "label": "Arts & Crafts" },
|
||||||
|
{ "value": "climbing", "label": "Climbing" },
|
||||||
|
{ "value": "dancing", "label": "Dancing" },
|
||||||
|
{ "value": "diving", "label": "Diving" },
|
||||||
|
{ "value": "englisch-camps", "label": "Englischcamps" },
|
||||||
|
{ "value": "englisch-toefl", "label": "Englisch TOEFL©" },
|
||||||
|
{ "value": "fishing", "label": "Fishing" },
|
||||||
|
{ "value": "german-camps", "label": "German Camps" },
|
||||||
|
{ "value": "horseback", "label": "Horseback Riding" },
|
||||||
|
{ "value": "husky", "label": "Husky Camp" },
|
||||||
|
{ "value": "icit", "label": "International Counsellor in Training (ICIT)" },
|
||||||
|
{ "value": "lifeguarding", "label": "Lifeguarding" },
|
||||||
|
{ "value": "language", "label": "Language" },
|
||||||
|
{ "value": "leadership", "label": "Leadership" },
|
||||||
|
{ "value": "multi-water", "label": "Multi Water Adventure" },
|
||||||
|
{ "value": "sailing", "label": "Sailing" },
|
||||||
|
{ "value": "skating", "label": "Skating" },
|
||||||
|
{ "value": "soccer", "label": "Soccer" },
|
||||||
|
{ "value": "space", "label": "Space Exploration" },
|
||||||
|
{ "value": "spanish", "label": "Spanishcourse" },
|
||||||
|
{ "value": "survival", "label": "Survival" },
|
||||||
|
{ "value": "swimming", "label": "Swimming" },
|
||||||
|
{ "value": "tennis", "label": "Tennis" },
|
||||||
|
{ "value": "windsurf", "label": "Windsurfing" }
|
||||||
|
],
|
||||||
|
"holidays": [
|
||||||
|
{ "value": "autumn", "label": "Autumn" },
|
||||||
|
{ "value": "spring", "label": "Spring" },
|
||||||
|
{ "value": "summer", "label": "Summer" }
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{ "value": "philippines", "label": "Philippines" },
|
||||||
|
{ "value": "vietnam", "label": "Vietnam" },
|
||||||
|
{ "value": "portugal", "label": "Portugal" },
|
||||||
|
{ "value": "china", "label": "China" },
|
||||||
|
{ "value": "thailand", "label": "Thailand" },
|
||||||
|
{ "value": "malaysia", "label": "Malaysia" },
|
||||||
|
{ "value": "holiday", "label": "Holiday" }
|
||||||
|
],
|
||||||
|
"camps": [
|
||||||
|
{
|
||||||
|
"name": "Adventure, Sports & Creative",
|
||||||
|
"price": 395,
|
||||||
|
"priceText": "from 395 USD",
|
||||||
|
"season": ["spring", "summer", "autumn"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["thailand"],
|
||||||
|
"image": "/uploads/booking/00_Abenteuercamp-Hike-533b20fa.jpg",
|
||||||
|
"link": "/activities/adventure-sports-creative",
|
||||||
|
"program": "adventure",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Arts & Crafts",
|
||||||
|
"price": 500,
|
||||||
|
"priceText": "from 500 USD",
|
||||||
|
"season": ["spring", "summer", "autumn"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["vietnam"],
|
||||||
|
"image": "/uploads/booking/01-Kreativprogramm-in-der-Ferienfreizeit-c6e95722.jpg",
|
||||||
|
"link": "/activities/arts-crafts",
|
||||||
|
"program": "arts-crafts",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Climbing",
|
||||||
|
"price": 515,
|
||||||
|
"priceText": "from 515 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["philippines"],
|
||||||
|
"image": "/uploads/booking/00-Kletterkurs_Sommercamp_Bayern-40f1bd8d.jpg",
|
||||||
|
"link": "/activities/climbing",
|
||||||
|
"program": "climbing",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dancing",
|
||||||
|
"price": 520,
|
||||||
|
"priceText": "from 520 USD",
|
||||||
|
"season": ["summer", "autumn"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["malaysia"],
|
||||||
|
"image": "/uploads/booking/00-Tanzen-im-Feriencamp-c1834fc7.jpg",
|
||||||
|
"link": "/activities/dancing",
|
||||||
|
"program": "dancing",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Diving",
|
||||||
|
"price": 1190,
|
||||||
|
"priceText": "from 1190 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["philippines"],
|
||||||
|
"image": "/uploads/booking/01-Tauchkurs-im-Sommercamp-3309e219.jpg",
|
||||||
|
"link": "/activities/diving",
|
||||||
|
"program": "diving",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Englisch TOEFL®",
|
||||||
|
"price": 1290,
|
||||||
|
"priceText": "from 1290 USD",
|
||||||
|
"season": ["spring", "summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["malaysia"],
|
||||||
|
"image": "/uploads/booking/07-Language-Camps-by-Camp-Adventure-b9f01b6a.jpg",
|
||||||
|
"link": "/activities/englisch-toefl",
|
||||||
|
"program": "englisch-toefl",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Englischcamps",
|
||||||
|
"price": 530,
|
||||||
|
"priceText": "from 530 USD",
|
||||||
|
"season": ["spring", "summer", "autumn"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["philippines", "thailand"],
|
||||||
|
"image": "/uploads/booking/00-Language-Camps-by-Camp-Adventure-add7aa60.jpg",
|
||||||
|
"link": "/activities/englischcamps",
|
||||||
|
"program": "englisch-camps",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fishing",
|
||||||
|
"price": 580,
|
||||||
|
"priceText": "from 580 USD",
|
||||||
|
"season": ["spring", "summer", "autumn"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["vietnam"],
|
||||||
|
"image": "/uploads/booking/01-Angeln-im-Ferienlager-02243939.jpg",
|
||||||
|
"link": "/activities/fishing",
|
||||||
|
"program": "fishing",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "German Camps",
|
||||||
|
"price": 610,
|
||||||
|
"priceText": "from 610 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["thailand", "vietnam"],
|
||||||
|
"image": "/uploads/booking/Deutschcamps-in-Deutschland-0ed3ea07.jpg",
|
||||||
|
"link": "/activities/german-camps",
|
||||||
|
"program": "german-camps",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Horseback Riding",
|
||||||
|
"price": 620,
|
||||||
|
"priceText": "from 620 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["portugal"],
|
||||||
|
"image": "/uploads/booking/00-Reiten-Sommercamp-Ausritt-6930f841.jpg",
|
||||||
|
"link": "/activities/horseback-riding",
|
||||||
|
"program": "horseback",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Husky Camp",
|
||||||
|
"price": 525,
|
||||||
|
"priceText": "from 525 USD",
|
||||||
|
"season": ["spring", "summer", "autumn"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["china"],
|
||||||
|
"image": "/uploads/booking/00-Husky20Camp_sommercamp20mit20Hunden-9c098a17.jpg",
|
||||||
|
"link": "/activities/husky-camp",
|
||||||
|
"program": "husky",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "International Counsellor in Training (ICIT)",
|
||||||
|
"price": 995,
|
||||||
|
"priceText": "from 995 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [16, 18],
|
||||||
|
"locations": ["thailand", "malaysia"],
|
||||||
|
"image": "/uploads/booking/00-INTERNATIONAL20COUNSELOR20IN20TRAINING_teambuilding-3b91547c.jpg",
|
||||||
|
"link": "/activities/international-counsellor-in-training-icit",
|
||||||
|
"program": "icit",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Leadership",
|
||||||
|
"price": 1185,
|
||||||
|
"priceText": "from 1185 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [16, 18],
|
||||||
|
"locations": ["philippines"],
|
||||||
|
"image": "/uploads/booking/00-Leadership-Camp-0d21c60a.jpg",
|
||||||
|
"link": "/activities/senior-plus-leadership",
|
||||||
|
"program": "leadership",
|
||||||
|
"rating": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lifeguarding",
|
||||||
|
"price": 580,
|
||||||
|
"priceText": "from 580 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["malaysia"],
|
||||||
|
"image": "/uploads/booking/00-Rettungsschwimmen-Feriencamp-6a364891.jpg",
|
||||||
|
"link": "/activities/lifeguarding",
|
||||||
|
"program": "lifeguarding",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Multi Water Adventure",
|
||||||
|
"price": 990,
|
||||||
|
"priceText": "from 990 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["philippines"],
|
||||||
|
"image": "/uploads/booking/00-Multi-Water-Adventure-im-Sommercamp-a47c08a3.jpg",
|
||||||
|
"link": "/activities/multi-water-adventure",
|
||||||
|
"program": "multi-water",
|
||||||
|
"rating": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sailing",
|
||||||
|
"price": 990,
|
||||||
|
"priceText": "from 990 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["thailand"],
|
||||||
|
"image": "/uploads/booking/01-Segeln-im-Sommercamp-in-Spanien-e9d06b28.jpg",
|
||||||
|
"link": "/activities/sailing",
|
||||||
|
"program": "sailing",
|
||||||
|
"rating": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skating",
|
||||||
|
"price": 420,
|
||||||
|
"priceText": "from 420 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["vietnam"],
|
||||||
|
"image": "/uploads/booking/00-Skaten im Sommercamp-8240a4c7.jpg",
|
||||||
|
"link": "/activities/skating",
|
||||||
|
"program": "skating",
|
||||||
|
"rating": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Soccer",
|
||||||
|
"price": 495,
|
||||||
|
"priceText": "from 495 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["malaysia"],
|
||||||
|
"image": "/uploads/booking/00-Soccer-Camps-543a1625.jpg",
|
||||||
|
"link": "/activities/soccer",
|
||||||
|
"program": "soccer",
|
||||||
|
"rating": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Space Exploration",
|
||||||
|
"price": 595,
|
||||||
|
"priceText": "from 595 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["china"],
|
||||||
|
"image": "/uploads/booking/00-Space-Exploration-Sommer-Camp-599962e5.jpg",
|
||||||
|
"link": "/activities/space-exploration",
|
||||||
|
"program": "space",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Spanish Camps",
|
||||||
|
"price": 595,
|
||||||
|
"priceText": "from 595 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["portugal"],
|
||||||
|
"image": "/uploads/booking/Spanischcamp-in-Spanien-d118b0e9.jpg",
|
||||||
|
"link": "/activities/spanish-camps",
|
||||||
|
"program": "spanish",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Survival",
|
||||||
|
"price": 495,
|
||||||
|
"priceText": "from 495 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["vietnam"],
|
||||||
|
"image": "/uploads/booking/03-Walsrode-Survival-e00c16d7.jpg",
|
||||||
|
"link": "/activities/survival",
|
||||||
|
"program": "survival",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Swimming",
|
||||||
|
"price": 495,
|
||||||
|
"priceText": "from 495 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["philippines"],
|
||||||
|
"image": "/uploads/booking/Schwimmen_camp-98f48b76.jpg",
|
||||||
|
"link": "/activities/swimming",
|
||||||
|
"program": "swimming",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tennis",
|
||||||
|
"price": 495,
|
||||||
|
"priceText": "from 495 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["malaysia"],
|
||||||
|
"image": "/uploads/booking/00-Tenniscamp-57cd2c79.jpg",
|
||||||
|
"link": "/activities/tennis",
|
||||||
|
"program": "tennis",
|
||||||
|
"rating": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Windsurfing",
|
||||||
|
"price": 990,
|
||||||
|
"priceText": "from 990 USD",
|
||||||
|
"season": ["summer"],
|
||||||
|
"age": [12, 18],
|
||||||
|
"locations": ["thailand"],
|
||||||
|
"image": "/uploads/booking/00-Windsurfen-im-Sommercamp-ac31b126.jpg",
|
||||||
|
"link": "/activities/windsurfing",
|
||||||
|
"program": "windsurf",
|
||||||
|
"rating": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"formSteps": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"title": "Participant Information",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "logistics",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "accommodation",
|
||||||
|
"label": "Accommodation",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "a1",
|
||||||
|
"label": "Accommodation in tiny houses/huts in the Adventure Camp",
|
||||||
|
"price": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transferTo",
|
||||||
|
"label": "Getting there",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "3",
|
||||||
|
"label": "Self-organized Arrival (4-6 pm)",
|
||||||
|
"price": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "351",
|
||||||
|
"label": "Shuttle Plattling - Meeting Point: Train Station platform 5 (at 3:30 pm)",
|
||||||
|
"price": 45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transferFrom",
|
||||||
|
"label": "Departure",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "3",
|
||||||
|
"label": "Self-organized Pick-up",
|
||||||
|
"price": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "351",
|
||||||
|
"label": "Shuttle Plattling - Train Station",
|
||||||
|
"price": 45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "activities",
|
||||||
|
"label": "Activity Profile",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "195",
|
||||||
|
"label": "Adventure, Sports and Creative (Basic profile)",
|
||||||
|
"price": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "addons",
|
||||||
|
"label": "Additional addons",
|
||||||
|
"type": "checkbox-group",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "8",
|
||||||
|
"label": "Travel Cancellation Guarantee (one week)",
|
||||||
|
"price": 45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "personal_details",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "firstName",
|
||||||
|
"label": "First name",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lastName",
|
||||||
|
"label": "Last name",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "birthday",
|
||||||
|
"label": "Birthday",
|
||||||
|
"type": "date",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gender",
|
||||||
|
"label": "Gender",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "female",
|
||||||
|
"label": "Female"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "male",
|
||||||
|
"label": "Male"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "divers",
|
||||||
|
"label": "Non binary"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nationality",
|
||||||
|
"label": "Nationality",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "Germany",
|
||||||
|
"label": "Germany"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "United States",
|
||||||
|
"label": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "United Kingdom",
|
||||||
|
"label": "United Kingdom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "France",
|
||||||
|
"label": "France"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "Spain",
|
||||||
|
"label": "Spain"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lodgingPartner",
|
||||||
|
"label": "Lodging partner",
|
||||||
|
"type": "text",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": 2,
|
||||||
|
"title": "Guardian Information",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "guardian_details",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "customerGender",
|
||||||
|
"label": "Salutation",
|
||||||
|
"type": "select",
|
||||||
|
"required": false,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "female",
|
||||||
|
"label": "Mrs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "male",
|
||||||
|
"label": "Mr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "divers",
|
||||||
|
"label": "Non binary"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerFirstName",
|
||||||
|
"label": "First name",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerLastName",
|
||||||
|
"label": "Last name",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerEmail",
|
||||||
|
"label": "E-Mail",
|
||||||
|
"type": "email",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerPhone",
|
||||||
|
"label": "Phone",
|
||||||
|
"type": "tel",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerStreet",
|
||||||
|
"label": "Street & Number",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerZip",
|
||||||
|
"label": "Zip",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerCity",
|
||||||
|
"label": "City",
|
||||||
|
"type": "text",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customerCountry",
|
||||||
|
"label": "Country",
|
||||||
|
"type": "select",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "Germany",
|
||||||
|
"label": "Germany"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "United States",
|
||||||
|
"label": "United States"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "United Kingdom",
|
||||||
|
"label": "United Kingdom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "France",
|
||||||
|
"label": "France"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "Spain",
|
||||||
|
"label": "Spain"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"step1Required": [
|
||||||
|
"accommodation",
|
||||||
|
"transferTo",
|
||||||
|
"transferFrom",
|
||||||
|
"activities",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"birthday",
|
||||||
|
"gender",
|
||||||
|
"nationality"
|
||||||
|
],
|
||||||
|
"step2Required": [
|
||||||
|
"customerFirstName",
|
||||||
|
"customerLastName",
|
||||||
|
"customerEmail",
|
||||||
|
"customerPhone",
|
||||||
|
"customerStreet",
|
||||||
|
"customerZip",
|
||||||
|
"customerCity",
|
||||||
|
"customerCountry"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"currency": "USD",
|
||||||
|
"discounts": [
|
||||||
|
{
|
||||||
|
"id": "915",
|
||||||
|
"name": "Sibling or Returning Camper Discount",
|
||||||
|
"type": "percentage",
|
||||||
|
"value": 0.05,
|
||||||
|
"description": "This discount is granted if your child has attended a Camp Adventure program before or if you register siblings."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9152",
|
||||||
|
"name": "Sibling or Returning Camper Discount",
|
||||||
|
"type": "percentage",
|
||||||
|
"value": 0.05,
|
||||||
|
"description": "This discount is granted if your child has attended a Camp Adventure program before or if you register siblings."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vouchers": [
|
||||||
|
{
|
||||||
|
"validCodes": "SUMMER2026",
|
||||||
|
"type": "percentage",
|
||||||
|
"value": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"validCodes": "SUMMER2027",
|
||||||
|
"type": "percentage",
|
||||||
|
"value": 0.05
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"validCodes": "CAMP50",
|
||||||
|
"type": "fixed",
|
||||||
|
"value": 50
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
354
data/camp-location.json
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"_comment": "Meta information for SEO and page header",
|
||||||
|
"title": "International Sport & Language Camp in Germany 2026",
|
||||||
|
"description": "International camps for kids from ages 7-19 ☀ Spend your holiday making friends from all over the world! ► Book now!"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"_comment": "Data for banner/hero section at the top of the page",
|
||||||
|
"title": "Our international Camps",
|
||||||
|
"backgroundImage": "/uploads/banner/b12.jpg"
|
||||||
|
},
|
||||||
|
"camps": [
|
||||||
|
{
|
||||||
|
"_comment": "Map markers data - contains GPS coordinates and representative images",
|
||||||
|
"id": 1,
|
||||||
|
"lat": 48.9701,
|
||||||
|
"lng": 13.1263,
|
||||||
|
"title": "Bayerischer Wald",
|
||||||
|
"image": "https://dynamic-media-cdn.tripadvisor.com/media/photo-o/11/1b/e5/23/trifter-klause-im-watzlik.jpg?w=1200&h=-1&s=1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"lat": 53.5597,
|
||||||
|
"lng": 9.9601,
|
||||||
|
"title": "Hamburg",
|
||||||
|
"image": "https://cdn-images.wework.com/images/69626CB4-BEDF-11EB-8766-0E6A5DC689CD/69626cb4-bedf-11eb-8766-0e6a5dc689cd_0.jpg?width=600"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"lat": 52.8625,
|
||||||
|
"lng": 9.5883,
|
||||||
|
"title": "Lüneburger Heide",
|
||||||
|
"image": "https://www.campadventure.de/templates/yootheme/cache/05/01-Sommercamp-Walsrode-Dining-Hall-outside-05806542.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"lat": 53.9246,
|
||||||
|
"lng": -3.00764,
|
||||||
|
"title": "Rossall School",
|
||||||
|
"image": "https://images.squarespace-cdn.com/content/v1/674e026f2e3d592ae64503b6/22ba03d4-bc8c-4c0a-8651-31932b1c16b6/Copy+of+Rossall+Archway+-+Prize+Day.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"lat": 51.3802,
|
||||||
|
"lng": -2.36674,
|
||||||
|
"title": "Bath University",
|
||||||
|
"image": "https://stories.bath.ac.uk/innovation-with-impact/landing-page/spin-out-club/assets/ylpLnicsFq/53233832409_7a68d40a42_o-2560x1440.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"lat": 53.6279,
|
||||||
|
"lng": 10.687,
|
||||||
|
"title": "Lauenburgische Seen",
|
||||||
|
"image": "https://www.herzogtum-lauenburg.info/wp-content/uploads/045_Abendimpression_Ratzebu.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"lat": 54.627,
|
||||||
|
"lng": 8.38935,
|
||||||
|
"title": "Amrum",
|
||||||
|
"image": "https://www.campadventure.de/templates/yootheme/cache/52/02-Language-Camps-by-Camp-Adventure-5201a531.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"lat": 41.4114,
|
||||||
|
"lng": 2.2281,
|
||||||
|
"title": "Barcelona Beach Camp",
|
||||||
|
"image": "https://www.gelc-language-camps.org/templates/yootheme/cache/bd/1_Header-bdc50a76.jpeg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locationsSection": {
|
||||||
|
"_comment": "Section title for camp locations and program options",
|
||||||
|
"title": "Our Camp Locations & Program Options",
|
||||||
|
"readMoreButtonText": "read more"
|
||||||
|
},
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"_comment": "Detailed location information - id must match camps[].id",
|
||||||
|
"id": 1,
|
||||||
|
"country": "Germany",
|
||||||
|
"title": "Go and Grow Camp",
|
||||||
|
"imageSrc": "/uploads/locations/wal_bigpic-46c21709.jpg",
|
||||||
|
"imageAlt": "Go and Grow Camp",
|
||||||
|
"readMoreLink": "/destinations/germany-lueneburger-heide",
|
||||||
|
"imagePosition": "left",
|
||||||
|
"cardSize": "default",
|
||||||
|
"programOptions": [
|
||||||
|
{
|
||||||
|
"_comment": "Available programs at this location",
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#creative",
|
||||||
|
"imageSrc": "/uploads/programs/wal_creativesportsadventure-a530744c.jpg",
|
||||||
|
"title": "Creative, Sports & Adventure",
|
||||||
|
"description": "Many great activities are waiting for you"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#language",
|
||||||
|
"imageSrc": "/uploads/programs/wal_germanenglish-11dc0bbb.jpg",
|
||||||
|
"title": "English/German",
|
||||||
|
"description": "Improve your language skills"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#lifeguarding",
|
||||||
|
"imageSrc": "/uploads/programs/wal_lifeguarding-9d1139de.jpg",
|
||||||
|
"title": "Lifeguarding",
|
||||||
|
"description": "Recognize & act in emergencies at and in the water"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#survival",
|
||||||
|
"imageSrc": "/uploads/programs/wal_survival-7ad276e4.jpg",
|
||||||
|
"title": "Survival",
|
||||||
|
"description": "The choice for all survivors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#soccer",
|
||||||
|
"imageSrc": "/uploads/programs/wal_soccer-e9f6b909.jpg",
|
||||||
|
"title": "Soccer",
|
||||||
|
"description": "Scoring goals has to be learned - our coaches teach you"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#horseriding",
|
||||||
|
"imageSrc": "/uploads/programs/wal_reiten-3cc39545.jpg",
|
||||||
|
"title": "Horseback Riding",
|
||||||
|
"description": "It's all about horses"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#husky",
|
||||||
|
"imageSrc": "/uploads/programs/wal_huskycamp-c46b92e9.jpg",
|
||||||
|
"title": "Husky Camp",
|
||||||
|
"description": "Learn more about the fascinating dogs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#leadership",
|
||||||
|
"imageSrc": "/uploads/programs/wal_leadership-7946a8ea.jpg",
|
||||||
|
"title": "Leadership",
|
||||||
|
"description": "The next step in camp life"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-lueneburger-heide#icit",
|
||||||
|
"imageSrc": "/uploads/programs/wal_cit-7b79f457.jpg",
|
||||||
|
"title": "Camp Counselor in Training (ICIT)",
|
||||||
|
"description": "Discover your strengths and become a teamer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"country": "Germany",
|
||||||
|
"title": "Adventure & Language Camp Bayerischer Wald",
|
||||||
|
"imageSrc": "/uploads/locations/REG_ADV_bigpic-d42b453f.jpg",
|
||||||
|
"imageAlt": "Adventure & Language Camp Bayerischer Wald",
|
||||||
|
"readMoreLink": "/destinations/germany-adventure-camp-bayerischer-wald",
|
||||||
|
"imagePosition": "right",
|
||||||
|
"cardSize": "large",
|
||||||
|
"programOptions": [
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-adventure-camp-bayerischer-wald#creative",
|
||||||
|
"imageSrc": "/uploads/programs/neu_profil_creativesportsadventure_querformat-01fb7b2b.jpg",
|
||||||
|
"title": "Creative, Sports & Adventure",
|
||||||
|
"description": "Many great activities are waiting for you"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-adventure-camp-bayerischer-wald#survival",
|
||||||
|
"imageSrc": "/uploads/programs/neu_profil_survival_hochformat-0218ce10.jpg",
|
||||||
|
"title": "Survival",
|
||||||
|
"description": "The choice for all survivors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-adventure-camp-bayerischer-wald#climbing",
|
||||||
|
"imageSrc": "/uploads/programs/reg_profil_klettern_querformat-86cc0bc7.jpg",
|
||||||
|
"title": "Climbing Course",
|
||||||
|
"description": "Pure adrenaline up to lofty heights"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-bayerischer-wald#engclassic",
|
||||||
|
"imageSrc": "/uploads/programs/reg_profil_englischclassic-50e45d03.jpg",
|
||||||
|
"title": "English Classic",
|
||||||
|
"description": "Improve your language skills in practice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-bayerischer-wald#engtoefl",
|
||||||
|
"imageSrc": "/uploads/programs/reg_profil_toefl-a7488ef5.jpg",
|
||||||
|
"title": "English TOEFL®",
|
||||||
|
"description": "Language training incl. certificate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/germany-bayerischer-wald#gerclassic",
|
||||||
|
"imageSrc": "/uploads/programs/reg_profil_deutschclassic-fd03850d.jpg",
|
||||||
|
"title": "German Classic",
|
||||||
|
"description": "Holiday-oriented language course with fun"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"country": "England",
|
||||||
|
"title": "Bath University",
|
||||||
|
"imageSrc": "/uploads/locations/bat_bigpic-7f627372.jpg",
|
||||||
|
"imageAlt": "Bath University",
|
||||||
|
"readMoreLink": "/destinations/en-england-bath-university",
|
||||||
|
"imagePosition": "right",
|
||||||
|
"cardSize": "large",
|
||||||
|
"programOptions": [
|
||||||
|
{
|
||||||
|
"href": "/destinations/en-england-bath-university#engclassic",
|
||||||
|
"imageSrc": "/uploads/programs/bat_profil_englischclassic-64bd53f1.jpg",
|
||||||
|
"title": "English Classic",
|
||||||
|
"description": "Learning in the motherland of language"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/en-england-bath-university#engtoefl",
|
||||||
|
"imageSrc": "/uploads/programs/bat_profil_englischtoefl-0a0f0b4f.jpg",
|
||||||
|
"title": "English TOEFL®",
|
||||||
|
"description": "Language training incl. certificate"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"country": "England",
|
||||||
|
"title": "Rossall School",
|
||||||
|
"imageSrc": "/uploads/locations/ros_bigpic-29267065.jpg",
|
||||||
|
"imageAlt": "Rossall School",
|
||||||
|
"readMoreLink": "/destinations/en-england-rossall-school",
|
||||||
|
"imagePosition": "left",
|
||||||
|
"cardSize": "large",
|
||||||
|
"scrollspyClass": "uk-animation-slide-right-small",
|
||||||
|
"programOptions": [
|
||||||
|
{
|
||||||
|
"href": "/destinations/en-england-rossall-school#engclassic",
|
||||||
|
"imageSrc": "/uploads/programs/ros_profil_englischclassic-8d5d3e02.jpg",
|
||||||
|
"title": "English Classic",
|
||||||
|
"description": "Learning in the motherland of language"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": "/destinations/en-england-rossall-school#engtoefl",
|
||||||
|
"imageSrc": "/uploads/programs/ros_profil_englischtoefl-1899bb78.jpg",
|
||||||
|
"title": "English TOEFL®",
|
||||||
|
"description": "Language training incl. certificate"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"country": "Spain",
|
||||||
|
"title": "Barcelona Beach camp",
|
||||||
|
"imageSrc": "/uploads/locations/AI_Kids_auf_Boot-3859030a.png",
|
||||||
|
"imageAlt": "Sailing trip at our Barcelona Beach Camp in Spain",
|
||||||
|
"readMoreLink": "/destinations/en-spain-barcelona",
|
||||||
|
"imagePosition": "right",
|
||||||
|
"cardSize": "large",
|
||||||
|
"programOptions": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"intro": {
|
||||||
|
"_comment": "General introduction text - displayed in IntroSection component",
|
||||||
|
"content": "Our international Adventure, Sports & Language Camps in Germany, England and Spain combine a welcoming, inclusive atmosphere with unique Go and Grow Camp energy! Fun, stress-free language workshops and the mixture of original camp games along with traditional sports lead to an unforgettable experience. Join in and meet plenty of new friends your age from all over the globe in one of our international camps!"
|
||||||
|
},
|
||||||
|
"faqSection": {
|
||||||
|
"_comment": "Section title for FAQ",
|
||||||
|
"title": "Frequently Asked Questions",
|
||||||
|
"buttonText": "More questions",
|
||||||
|
"buttonIcon": "comments",
|
||||||
|
"buttonLink": "/info/faq"
|
||||||
|
},
|
||||||
|
"faq": [
|
||||||
|
{
|
||||||
|
"_comment": "FAQ #1: Languages spoken in camp",
|
||||||
|
"question": "Which languages are spoken in camp?",
|
||||||
|
"answer": "The main language in all our camps is English. In addition, there is the language of the country in which the camp takes place. As we have our headquarters in Germany, German teamers are always present in all camps. All announcements and explanations are therefore always in German and English. Of course, all our teamers with their different nationalities are also available for individual translations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Who are the camp counselors?",
|
||||||
|
"answer": "Every year our team is made up of an international mix. The non-profit association Camp Europe e.V. with headquarters in Hamburg and a branch office in Canada takes care of the acquisition of national and international applicants. Since we have about 50% German-speaking children, there are also German carers in every location. But many also come from other countries, such as England, Spain, Australia, Canada or Australia, to name just a few."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Should 12-year-olds rather go to Junior Camp or Senior Camp?",
|
||||||
|
"answer": "This question is not easy to answer and depends on the individual stage of development of your child. Therefore, as parents, we leave you the opportunity to decide for yourself. In the Junior Camp they belong to the older ones and can explore a lot in a playful way. In the Senior Camp they are the younger ones, who have role models through the older ones, whom they can emulate."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How do you provide safety for the kids?",
|
||||||
|
"answer": "Before our camp counselors start working with us, we check their police clearance certificates. You must be at least 19 years old to work for us as a teamer. They must also have a \"First Aid Certificate\", which must not be older than two years. In the camps we try to make sure that only adults from our camp or familiar faces are on the campground and that all our carers look after strangers.\n\nWe have many different camp sites. Some of them are fenced in, others are not. There are no armed guards or the like in our camps, as we believe that these conditions create a very insecure feeling. We do not have a high security zone in Germany, Northern Ireland or England, but we keep our eyes open and do everything we can to ensure that all participants have a great time."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How is my child accommodated in camp?",
|
||||||
|
"answer": "In the Creative, Sports & Adventure Camp in Neuburg the participants are accommodated in tents with 6-7 persons. These are equipped with a floor and a wooden gallery. The participants can make themselves comfortable with sleeping bag and sleeping mat. In Walsrode the Juniors (7-12) and the Seniors (12-16) can choose between tent and hut. The wooden cabins are equipped with bunk beds and can accommodate 4-8 children each. In the other locations, participants are accommodated in shared rooms in youth hostels, sports centres or boarding schools run by private schools. Detailed information about the accommodation can be found at the various camps."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Where do kids and camp counselors come from?",
|
||||||
|
"answer": "Go and Grow Camp attaches great importance to internationality. The participants and supervisors in our camps come from many different countries. Last year, for example, we had participants from over 60 different countries and supervisors from 25 different nations. Of course we don't know where they will come from this year. So we are at least as excited as you are.\n\nEvery year, our team is made up of an international mixture. Through our office in Hamburg and our branch office in Canada, we reach motivated and committed advisors from all over the world. Canadian and Australian teamers can therefore be found as well as German or Spanish teamers. Due to the different experiences and cultural backgrounds an indescribably fantastic, international atmosphere is created."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How is the choice of activities/courses in the camps made?",
|
||||||
|
"answer": "If your child would like to participate in a paid profile (e.g. horse riding, language course, GEOlino), this must be booked in advance when registering. In principle, no extra profiles have to be booked. A program with a variety of activities is of course available to the participants in all camps. The various activities can be chosen by the participants on site in the respective camps. We present the offers to the participants, so that everyone gets an insight into the different courses. The children can then register in the lists of the respective courses."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "How big are the camps? How high is the caregiver key?",
|
||||||
|
"answer": "Capacities range from around 30 participants in smaller language camps to a maximum of 350 children. However, the maximum capacity is not reached every week. However, a minimum number of participants must be guaranteed in order to run the camp.\n\nIt is important to us that all children are always grouped in small groups of 5-8, with a supervisor as contact person. This way homesickness does not get a chance and despite the size of the camp in their group family, they experience a strong cohesion on which they can count!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "When do I have to pay the price for the booked camp?",
|
||||||
|
"answer": "The deposit must be paid within 7 days of receipt of the booking confirmation. The remaining payment for the booked camp is due 4 weeks before the start of the camp at the latest. Further information can be found in our Terms & Conditions."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"welcomeQuote": {
|
||||||
|
"_comment": "Testimonial quote displayed in WelcomeQuote section",
|
||||||
|
"title": "Welcome to Go and Grow Camp",
|
||||||
|
"quote": "Both my children really enjoyed Go and Grow Camp. The teamers did a great job making them feel comfortable and at home. My son was unsure if he wanted to stay at first but he felt part of his tent group and camp very quickly and loved it. Both had tons of fun. Thank you CA!",
|
||||||
|
"author": "Anja B., Germany"
|
||||||
|
},
|
||||||
|
"securityConcept": {
|
||||||
|
"_comment": "Security policy - 9 measures: Background Check, Education, Crisis Intervention, Caregiver Key, Nightwatch, Cooperation, Quality, Accessibility, Emergency",
|
||||||
|
"title": "Security Concept",
|
||||||
|
"introduction": "Dear parents,\n\nwe are very happy that you want us to take care of the sweetiest and the best of your life. Be sure: we will protect it as if it is our own child and do everything we can to guarantee supreme safety. We would like to introduce you a part of our safety concept, if you have any questions feel free to contact us.",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"_comment": "Security measure #1: Staff background verification",
|
||||||
|
"title": "Background Check",
|
||||||
|
"content": "Every counselor, chef, teamer who enters our camps has to be registrated, needs a background check as well as references. That's why parents are only allowed on the day of arrival and departure and not during the camp week. We want to make sure that we know and checked every adult who is with us at camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Education",
|
||||||
|
"content": "Each supervisor must complete an almost two-week training course with us, which from early in the morning until late in the evening includes so many lessons that the number of hours even corresponds to the basic study in educational sciences. Here we focus on the areas of safety, accident prevention, child psychology and needs as well as the various safety aspects in the field of experiential education."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Crisis Intervention",
|
||||||
|
"content": "If something should happen, it is not only important to provide first aid for the affected person, but also to care for the other children and adolescents. We have a specially trained team for crisis intervention, which then provides immediate care and can thus prevent possible traumatisation due to the experience."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Caregiver Key",
|
||||||
|
"content": "No safety without sufficient staff! We are the leaders in Germany with our great caregiver key. There are no camps that have a key worse than 1:6-1:8, which means that one caregiver is responsible for a maximum of 6-8 children. In the junior camps we also use our CIT (Counselor in Training), so that we often reach a key of only 1:4. We know that this key can seem exaggerated, but we want to guarantee the highest possible safety and we firmly believe that this is exactly what our high level of caregiver commitment leads to."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nightwatch",
|
||||||
|
"content": "All our camps are also supervised at night by the counselors/teamers. On the one hand we want to prevent visitors from coming to the site - which has not happened until today - and on the other hand we want to be there for the children when they wake up at night and get homesick or have to go to the toilet. The nightwatch patrols the area and is otherwise reachable at a central place for the children. Some of our locations - e.g. the headquarters in Walsrode - are also video-monitored and fenced in."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cooperation",
|
||||||
|
"content": "Cooperation with the independent representative for questions of sexual child abuse via our umbrella organisation Reisenetz e.V.: Go and Grow Camp was one of the first tour operators for children and young people to develop a protection concept that prevents sexual abuse among children and young people. Today, this concept is considered important by many other tour operators, also due to our personal commitment in various associations and professional circles. Of course, the background check and the \"6-eyes principle\", which states that a child must never be alone with a caregiver, is also an essential part of our protection concept. The most important thing, however, is to create an \"open system\" in which everyone knows that sexual abuse should not be a taboo subject, but that simple instruments such as a grievance box and feedback system can immediately address grievances and that they do not have to be denied."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Quality",
|
||||||
|
"content": "As a member of the quality committee of the professional association for children and youth travel \"Reisenetz\", our managing director Jan Vieth is responsible for further developing and checking the quality guidelines of the entire industry. As Germany's ambassador to the ICF, he is also kept up to date on improvements in camp and training quality worldwide and adapts these as quickly as possible to our own camps."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Accessibility",
|
||||||
|
"content": "Of course, all parents receive a number from us, which allows them to reach us 24 hours a day in an emergency. If an emergency occurs at your home, you can inform us immediately and we can decide together how, when and whether to inform your child."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "In case of emergency",
|
||||||
|
"content": "Every caregiver has a valid first aid certificate and can help if necessary."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
95
data/contact-data.json
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Contact Us",
|
||||||
|
"backgroundImage": "/uploads/banner/b10.jpg",
|
||||||
|
"overlayColor": "rgba(0, 0, 0, 0)",
|
||||||
|
"sectionClass": "uk-section-secondary uk-section-overlap uk-preserve-color uk-light",
|
||||||
|
"titleClass": "uk-heading-large uk-text-center !text-[5vw]",
|
||||||
|
"enableScrollspy": true,
|
||||||
|
"backgroundPosition": "top-center"
|
||||||
|
},
|
||||||
|
"contactCards": [
|
||||||
|
{
|
||||||
|
"type": "phone",
|
||||||
|
"title": "Phone Number",
|
||||||
|
"content": ["+123456789"],
|
||||||
|
"iconType": "fas fa-phone",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"title": "Email Address",
|
||||||
|
"content": ["office@ggcamp.org"],
|
||||||
|
"iconType": "fas fa-envelope",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "location",
|
||||||
|
"title": "Our Location",
|
||||||
|
"content": ["Poblacion, Madridejos 22, Cebu City, Philippines"],
|
||||||
|
"iconType": "fas fa-map-marker-alt",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "hours",
|
||||||
|
"title": "Working hours",
|
||||||
|
"content": ["Monday to Saturday: 07pm - 05am", "Sunday: Closed"],
|
||||||
|
"iconType": "fas fa-clock",
|
||||||
|
"iconSource": "fontawesome"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"map": {
|
||||||
|
"coordinates": {
|
||||||
|
"lat": 10.3157,
|
||||||
|
"lng": 123.8854
|
||||||
|
},
|
||||||
|
"zoom": 15,
|
||||||
|
"location": "Poblacion, Madridejos 22, Cebu City, Philippines",
|
||||||
|
"markerTitle": "Our Office",
|
||||||
|
"tileLayer": {
|
||||||
|
"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"attribution": "© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors",
|
||||||
|
"maxZoom": 18,
|
||||||
|
"minZoom": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"sectionLabel": "Contact Us",
|
||||||
|
"heading": "Let's plan your dream adventure - contact our team today",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "firstName",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "First name",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lastName",
|
||||||
|
"type": "text",
|
||||||
|
"placeholder": "Last name",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "phone",
|
||||||
|
"type": "tel",
|
||||||
|
"placeholder": "Phone Number",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"type": "email",
|
||||||
|
"placeholder": "Email Address",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "message",
|
||||||
|
"type": "textarea",
|
||||||
|
"placeholder": "Send Message",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"submitButton": {
|
||||||
|
"text": "Send Message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
data/dataheader.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Academics",
|
||||||
|
"url": "/academics/",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"title": "Foundations",
|
||||||
|
"url": "/academics/foundations/",
|
||||||
|
"children": [],
|
||||||
|
"programmes": [
|
||||||
|
{
|
||||||
|
"title": "Pre-A",
|
||||||
|
"url": "/academics/foundations/PAF1000/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pre-U",
|
||||||
|
"url": "/academics/foundations/PUF1000/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Undergraduate",
|
||||||
|
"url": "/academics/undergraduate/",
|
||||||
|
"children": [],
|
||||||
|
"programmes": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Postgraduate",
|
||||||
|
"url": "/academics/postgraduate/",
|
||||||
|
"children": [],
|
||||||
|
"programmes": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Global Education",
|
||||||
|
"url": "/academics/global-education/",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"title": "Postgraduate Online",
|
||||||
|
"url": "/academics/postgraduate-online/",
|
||||||
|
"children": [],
|
||||||
|
"programmes": [
|
||||||
|
{
|
||||||
|
"title": "Accounting and Finance",
|
||||||
|
"url": "/academics/postgraduate-online/GE7002/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "International Business Law",
|
||||||
|
"url": "/academics/postgraduate-online/GE7008/"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
234
data/faq-data.json
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Go and Grow Camp",
|
||||||
|
"backgroundImage": "/uploads/home/b2.jpg",
|
||||||
|
"overlayColor": "rgba(0, 0, 0, 0)",
|
||||||
|
"sectionClass": "uk-section-secondary uk-section-overlap uk-preserve-color uk-light",
|
||||||
|
"titleClass": "uk-heading-large uk-text-center !text-[5vw]",
|
||||||
|
"enableScrollspy": true,
|
||||||
|
"backgroundPosition": "top-center"
|
||||||
|
},
|
||||||
|
|
||||||
|
"sidebarNav": [
|
||||||
|
{
|
||||||
|
"id": "general-information",
|
||||||
|
"label": "General Information"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camps",
|
||||||
|
"label": "Camps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camp-routine",
|
||||||
|
"label": "Camp Routine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camp-counselors",
|
||||||
|
"label": "Camp Counselors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camp-rules",
|
||||||
|
"label": "Camp Rules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "safety",
|
||||||
|
"label": "Safety"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "accommodation-catering",
|
||||||
|
"label": "Accommodation & Catering"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "transfers-shuttles",
|
||||||
|
"label": "Transfers & Shuttles"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"contactBox": {
|
||||||
|
"title": "Let's plan your perfect nature escape",
|
||||||
|
"phone": {
|
||||||
|
"icon": "phone",
|
||||||
|
"text": "+(123)-456-789"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"icon": "email",
|
||||||
|
"text": "hello@ggcamp.org"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"faqSections": [
|
||||||
|
{
|
||||||
|
"id": "general-information",
|
||||||
|
"title": "General Information",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "What are FAQ?",
|
||||||
|
"description": "FAQ are the initials for \"Frequently Asked Questions\".\n\nThe FAQ have been compiled by us over a long period of time and are intended to help give a general overview of our camps and clarify questions that arise before booking a camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "General booking process",
|
||||||
|
"description": "Once the booking has been confirmed by us, you will receive an e-mail requesting a deposit. As soon as we have received this, you will receive an e-mail with a payment confirmation.\nPlease have a look at the welcome package, which will reach you by e-mail with the Last Travel Information. This contains information that applies to the camp you have booked.\n\nStep 1: Registration\nStep 2: Receipt of registration confirmation, total invoice and deposit request (e-mail)\nStep 3: Deposit of USD 50 (due within 7 days after booking)\nStep 4: Receiving an email with the latest important travel information, a packing list, addresses and important emergency phone numbers plus remaining payment request about 3-4 weeks before the camp starts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Terms & Conditions",
|
||||||
|
"description": "Our Terms & Conditions can be found in our official documents section."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Where can I find a packing guide for Camps?",
|
||||||
|
"description": "Just click here to download our packing list."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Where can I find contact information from Camps and addresses?",
|
||||||
|
"description": "Here you can find all the necessary information if you want to drive to our camps or send something. If you want to send something please ALWAYS include the full name of your child on the letter/package and please only send it at the time when your kids are staying in camp as we cannot store it for a longer period of time.\n\nWalsrode/Lüneburger Heide - Germany:\nCamp Adventure, Vethem 58, 29664 Walsrode, Germany\nwalsrode@campadventure.de\n\nRegen/Bavarian Forest - Germany:\nCamp Adventure, Badstrasse 18, 94209 Regen\nregen@campadventure.de\n\nBarcelona - Spain:\nBISC International Sailing Center, c/o Camp Adventure, Parc del Fòrum Sota plaça fotovoltàica, 08930 Sant Adrià de Besòs, Barcelona, Spain\nbarcelona@campadventure.de\n\nBath - England:\nUniversity of Bath, c/o Camp Adventure, Claverton Down, Bath BA2 7AY, England\nengland@campadventure.de\n\nRossall - England:\nRossall School, Broadway, Fleetwood, Lancashire FY7 8JW, England\nengland@campadventure.de"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camps",
|
||||||
|
"title": "Camps",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "Where do kids and camp counselors come from?",
|
||||||
|
"description": "Camp Adventure attaches great importance to internationality. The participants and supervisors in our camps come from many different countries. Last year, for example, we had participants from over 60 different countries and counselors from 25 different nations. Of course, we don't know where they will come from this year. So we are at least as excited as you are.\n\nThrough our office in Hamburg and our branch office in Canada, we reach motivated and committed counselors from all over the world. Canadian and Australian teamers can therefore be found as well as German or Spanish teamers.\n\nDue to the different experiences and cultural backgrounds an indescribably fantastic, international atmosphere is created."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Which languages are spoken in camp?",
|
||||||
|
"description": "The main language in all our camps is English. In addition, there is the language of the country in which the camp takes place. As we have our headquarters in Germany, German teamers are always present in all camps in Germany. All announcements and explanations are here therefore always in German and English. Of course, all our teamers with their different nationalities are also available for individual translations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Are there problems if children have low language skills?",
|
||||||
|
"description": "No, because there are usually more participants and team members who speak the same language. We know from experience that children are excellent at communicating nonverbally. They often need a few days to warm up to it, but are then very open to other children as well."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Are girls and boys separated?",
|
||||||
|
"description": "Girls and boys are accommodated separately in the dormitories/tents. The program is completely mixed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How big are the camps? How high is the caregiver ratio?",
|
||||||
|
"description": "Capacities range from around 30 participants in smaller language camps to a maximum of about 400 children in our camp Lueneburger Heide. However, the maximum capacity is not reached every week. However, a minimum number of participants must be guaranteed in order to run the camp.\n\nIt is important to us that all children are always grouped in small groups of 5-8, with a counselor as a contact person. This way homesickness doesn't stand a chance and despite the size of the camp in their group family, they experience a strong bond on which they can count on!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Should 12-year-olds go to Junior Camp or Senior Camp?",
|
||||||
|
"description": "This question is not easy to answer and depends on the individual stage of development of your child. Therefore, as parents, we leave you the opportunity to decide for yourself. In the Junior Camp they belong to the older ones and can explore a lot in a playful way. In the Senior Camp they are the younger ones, who have role models through the older ones, whom they can emulate."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camp-routine",
|
||||||
|
"title": "Camp Routine",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "How is the choice of activities/courses in the camps made?",
|
||||||
|
"description": "If your child would like to participate in a paid additional course (e.g. horse riding, language course, Survival etc.), this must be booked in advance when registering. In principle, no extra additional courses have to be booked. A program with a variety of activities is of course available to the participants in all camps. The various activities can be chosen by the participants on site in the respective camps. We present the offers to the participants, so that everyone gets an insight into the different courses. The children can then register in the lists of the respective courses."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "What is a hike?",
|
||||||
|
"description": "The hike is a 1-3 day walking tour, in which all participants of the Adventure Camp who stay 2 weeks in the camp take part. On this hike the participants will not spend the night in a tent, but either in the open air or under a self-made shelter e.g. from tarpaulins. They will of course be accompanied by their teamers. The hike is a very special experience and a highlight for all participants. For this hike the participants need sturdy shoes and a big backpack."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Can I wash my clothes during the camp?",
|
||||||
|
"description": "In principle, participants should bring sufficient clothing and change of clothes for the entire camp period.\n\nOnly in the camps in Lüneburger Heide and Bayerischer Wald a laundry service will be offered for kids staying three weeks or more, which means that a laundry bag (approx. 3 kg) will be washed in the laundry centre of the next village at a price of USD 45. This service can be booked upon registration for three-week camps. Please note that the laundry will be done either after one week or after two weeks."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Anti Homesick Adviser",
|
||||||
|
"description": "Dear parents\n\nNow it's almost time: In summer your child travels for the first time with Camp Adventure. Maybe it will be the first time that he travels alone without parents or relatives. As we are getting more and more questions, we have decided to put together a small package for you parents with little tips from experts to make everything as easy as possible for you and your child. Follow our tips and your child will have a fantastic holiday, have many new experiences and make friends from all over the world! All these tips have been developed together with the International Camping Fellowship. And the more you think your child will be a \"homesick candidate\" - or your child even claims to be one - the more you consider the following tips."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camp-counselors",
|
||||||
|
"title": "Camp Counselors - Our Teamers",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "Who are the camp counselors?",
|
||||||
|
"description": "Every year our team is made up of an international mix. The non-profit association Camp Europe e.V. with headquarters in Hamburg and a branch office in Canada takes care of the acquisition of national and international applicants. Since we have about 50% German-speaking children, there are also German carers in every location. But many also come from other countries, such as England, Spain, Canada and Australia, to name just a few."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How are the teamers trained?",
|
||||||
|
"description": "All counselors go through an extensive application process. For a successful application, not only an interesting curriculum vitae and a minimum age of 19 years are sufficient! We conduct a personal interview with each individual in which our employees get a first impression of the applicant.\n\nBefore the camp season, everyone, both the first supervisors (teamers) as well as many recomers, complete a one-week training in which they are prepared for their assignment by trained coaches. They must have a first aid certificate, which may not be older than two years, as well as an internationally flawless police clearance certificate. We know how important the teamers are for a great camp and therefore select them very conscientiously."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "camp-rules",
|
||||||
|
"title": "Camp Rules",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "Drugs, Alcohol & Camp?",
|
||||||
|
"description": "From our point of view an absolutely unacceptable and indiscutable combination! Due to our cooperation with the association \"Keine Macht den Drogen\" (No power to drugs) and our common opinion that all kinds of drugs do not belong in the hands of children & teenagers, any possession or consumption of drugs is forbidden for teenagers and children in the camp and also outside the camp.\n\nViolations can lead to exclusion or even to criminal charges. The term \"drugs\" also includes cigarettes and alcohol! Through our varied activities, we offer a much better alternative! We would like to make it clear from the outset that we are also against any form of discrimination or \"putting down\". This is - just like violence - immediately prevented by us, in order to offer each young person a relaxed and joyful time in the camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Should I call my kid or write an old-fashioned letter?",
|
||||||
|
"description": "We ask all parents to write to their child at least once. This is especially useful at the beginning, as it is a particularly upsetting experience for every child and every teenager when most of the participants receive a letter, but they do not.\n\nPlease note that there is NO public \"camp phone\" available for incoming or outgoing calls. If your child doesn't bring her/his own phone, she/he won't be able to call you. In case of any problems, we will of course contact you immediately.\n\nIf your child brings a mobile phone, we will collect it on arrival and store it with the valuables. Your child's Teamer may hand it over during the phone time after lunch. Please keep in mind: no news is good news (the location manager will contact you if it is necessary due to homesickness or illness). We kindly ask you not to call the office in Hamburg to ask about your kid's health and wellbeing, nor if you would like to know why your child hasn't called you yet. Please use our camp email service for such enquiries.\n\nOur recommendation is the following:\nWe recommend not to call your child (even if he or she has a mobile phone with him or her) and not to tell him or her to call you. Telephoning can in our experience promote homesickness very strongly and your child will be cured thereby if completely immersed in camp life! At noon after lunch, if absolutely necessary, your child can pick up his or her mobile phone from the counselors until the start of next program and make phone calls. Instead, you are welcome to bring a pre-stamped and addressed envelope with you. We will then make sure that your child has enough time to write letters. Since letters and postcards often arrive late at the camps, we also offer the e-mail service. You can send your child max. ONE email per day directly to the camp, which we then print out and give to your child. There is no way for them to reply, but your child will be happy to receive a small message from home. You can find the postal and email address in the info package of the booked camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Are there any prohibited items?",
|
||||||
|
"description": "Yes, there are. Not allowed are pocket knives with lockable blades, all weapons, lighters and matches (danger of fire in the forest!). Drugs of any kind, including alcohol and cigarettes, are also included."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "safety",
|
||||||
|
"title": "Safety",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "Electronic equipment and valuables",
|
||||||
|
"description": "We recommend that you do not take an MP3 player, e-book, tablet, etc. or any valuables with you. On the one hand we do not assume any liability and on the other hand there are no possibilities to charge the devices. We are of the opinion that the camp time is a special experience for the participants if they do not have the headphones in their ears all the time or are busy with their mobile phones. Instead they have the chance to deal with other topics and they find time to dedicate themselves to the new people in the camp."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How do you provide safety for the kids?",
|
||||||
|
"description": "Before our camp counselors start working with us, we check their police clearance certificates. You must be at least 19 years old to work for us as a teamer. They must also have a \"First Aid Certificate\", which must not be older than two years. In the camps we try to make sure that only adults from our camp or familiar faces are on the campground and that all our carers look after strangers.\n\nWe have many different camp sites. Some of them are fenced in, others are not. There are no armed guards or the like in our camps, as we believe that these conditions create a very insecure feeling. We do not have a high security zone in Germany, Northern Ireland or England, but we keep our eyes open and do everything we can to ensure that all participants have a great time."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Insurance in case of illness?",
|
||||||
|
"description": "If your child should fall ill during the camp and medical help is required, he or she will of course be taken to the doctor by our carers and cared for there as well. It is therefore necessary for each participant to take their insurance card with them to the camp. We offer all participants the possibility of taking out liability, casualty & health insurance for travel abroad with us. This covers all costs in case of illness and prevents international children in particular from having to \"advance\" their own cash. You can find more detailed information on insurance in our documents section."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "accommodation-catering",
|
||||||
|
"title": "Accommodation & Catering",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "How's the food at the camps?",
|
||||||
|
"description": "Full board for the entire duration of the camp is of course already included in the camp price. In addition, water and fruit are available for the participants around the clock. For us it is a matter of course to provide one variant for vegetarians and one pork-free with each meal. In case of special allergies or intolerances of your children let us know in advance and we will try to find a solution."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How is my child accommodated in the camp?",
|
||||||
|
"description": "In our Adventure Camp Bayerischer Wald and our Camp Lueneburger Heide, the Juniors (7-12) and the Seniors (12-16) can choose between tents and huts.\n\nThe tents are equipped with a floor and a wooden platform, up to 7 children can share one tent. The participants can make themselves comfortable with sleeping bag and sleeping mat. The wooden huts are equipped with bunk beds and can accommodate 4-8 children. At the other locations, participants will be accommodated in shared rooms in youth hostels, sports centres or boarding schools of private schools. You will find detailed information about the accommodation on the individual camp pages."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "transfers-shuttles",
|
||||||
|
"title": "Transfers & Shuttles",
|
||||||
|
"faqs": [
|
||||||
|
{
|
||||||
|
"title": "Entry regulations/Travel Consent for group flights",
|
||||||
|
"description": "All parents need to fill this out and bring it to camp:\n\nBelow is a summary of the travel requirements for minors from various EU countries traveling with Camp Adventure on group flights. Please note that regulations can change, so it's essential to consult the official resources provided for the most up-to-date information."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Which transfers are offered?",
|
||||||
|
"description": "The respective transfer possibilities depend on the period and venue of the camp. Check directly on the respective camp page under \"Arrival & Departure Services\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Where can I find the exact arrival and departure times?",
|
||||||
|
"description": "Information about the different arrival and departure times can be found on the respective camp page under \"Arrival & Departure Services\"."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How do the transfer costs come about?",
|
||||||
|
"description": "When booking a train or air trip, the indicated price includes the arrival and departure as well as the accompaniment by a supervisor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Where can I find the address/driving directions from the camp?",
|
||||||
|
"description": "You will receive the exact address and directions of the camp with the Last Travel Information about 3-4 weeks before the camp starts."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"video": {
|
||||||
|
"url": "https://www.youtube.com/embed/3NtE5wSwYTo?list=PLSOedrxa1c-bxvH6uuz_oZdIfJkov66wB&disablekb=1",
|
||||||
|
"title": "Anti Homesickness Adviser"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
data/footer.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"about": {
|
||||||
|
"title": "About GGC",
|
||||||
|
"description": "Welcome to Go and Grow Camp, where adventure, learning, and friendships come together. Join us for unforgettable experiences that inspire confidence, creativity, and connection",
|
||||||
|
"mapLink": {
|
||||||
|
"text": "Check on google map",
|
||||||
|
"url": "https://share.google/8G4LFRq82OwOmlFlN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"text": "Poblacion, Madridejos 22, Cebu City, Philippines",
|
||||||
|
"address2": "",
|
||||||
|
"link2": "",
|
||||||
|
"mapUrl": "https://share.google/8G4LFRq82OwOmlFlN"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"phone": "+123456789",
|
||||||
|
"hours": "Mon - Fri 8.00-18.00",
|
||||||
|
"email": "office@ggcamp.org"
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"title": "Explore",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"title": "Home",
|
||||||
|
"url": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Activities",
|
||||||
|
"url": "/destinations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Camp Locations",
|
||||||
|
"url": "/camp-profiles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "About",
|
||||||
|
"url": "/info/about"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Contact",
|
||||||
|
"url": "/info/contact"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"social": {
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"platform": "Facebook",
|
||||||
|
"url": "https://www.facebook.com/campadventuregermany/",
|
||||||
|
"icon": "facebook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "Twitter",
|
||||||
|
"url": "#",
|
||||||
|
"icon": "twitter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "Instagram",
|
||||||
|
"url": "https://www.instagram.com/campadventuregermany/",
|
||||||
|
"icon": "instagram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "Behance",
|
||||||
|
"url": "#",
|
||||||
|
"icon": "behance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "LinkedIn",
|
||||||
|
"url": "#",
|
||||||
|
"icon": "linkedin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"copyright": {
|
||||||
|
"text": "© 2025 GGC @ All Rights Reserved"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
data/header.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"topbar": {
|
||||||
|
"contactInfo": {
|
||||||
|
"phone": "+(123)-456-789",
|
||||||
|
"email": "info@ggcamp.com"
|
||||||
|
},
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"text": "Instagram",
|
||||||
|
"url": "https://instagram.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Facebook",
|
||||||
|
"url": "https://facebook.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Dribbble",
|
||||||
|
"url": "https://dribbble.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"logo": "/templates/yootheme/cache/c9/logo-camp-adventure-c9850ee6.png"
|
||||||
|
}
|
||||||
|
|
||||||
537
data/home.json
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Discover Adventure and Friendship",
|
||||||
|
"description": "Step into a world where adventure meets comfort. Discover breathtaking landscapes, thrilling outdoor activities, and the serenity of luxury camping.",
|
||||||
|
"backgroundImage": "/uploads/home/b2.jpg",
|
||||||
|
"button": {
|
||||||
|
"label": "Book Your Adventure",
|
||||||
|
"href": "/booking"
|
||||||
|
},
|
||||||
|
"contactBox": {
|
||||||
|
"welcomeText": "Your Adventure Journey Start Here!",
|
||||||
|
"phone": {
|
||||||
|
"label": "Call us",
|
||||||
|
"number": "+(123) 456 789",
|
||||||
|
"href": "tel:+123456789"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"label": "Email",
|
||||||
|
"address": "office@ggcamp.org",
|
||||||
|
"href": "mailto:office@ggcamp.org"
|
||||||
|
},
|
||||||
|
"workingHours": {
|
||||||
|
"label": "Working Hours",
|
||||||
|
"hours": "Monday-Saturday: 08:pm to 05:am"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "- About Us",
|
||||||
|
"subtitle": "Creating Amazing Camps",
|
||||||
|
"description": "Learning is closely tied to practical experience—summer is the perfect time for hands-on opportunities. While knowledge must still be nurtured, it can take on new and more engaging forms.",
|
||||||
|
"images": {
|
||||||
|
"mainImage1": "/uploads/home/a1.jpg",
|
||||||
|
"mainImage2": "/uploads/home/a2.jpg",
|
||||||
|
"avatars": [
|
||||||
|
"/uploads/home/profile-face_1.jpg",
|
||||||
|
"/uploads/home/young-tourist-sitting-tent.jpg",
|
||||||
|
"/uploads/home/portrait-young-male-tourist-standing-forest-with-tent.jpg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
"Fun-Filled Experiences for Every Camper",
|
||||||
|
"Adventures That Inspire Confidence and Growth",
|
||||||
|
"Memories and Friendships That Last a Lifetime"
|
||||||
|
],
|
||||||
|
"quote": "\"Your Journey, Your Comfort, Your Adventure.\"",
|
||||||
|
"button": {
|
||||||
|
"label": "Learn More About",
|
||||||
|
"href": "/info/about"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"customerCount": 50,
|
||||||
|
"customerLabel": "Adventurer with\nhappy customer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"missionVision": {
|
||||||
|
"title": "- Who We Are",
|
||||||
|
"subtitle": "Company Mission & Vision",
|
||||||
|
"backgroundImage": "/uploads/home/b8.jpg",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"title": "Our Mission",
|
||||||
|
"description": "We provide a safe, inclusive, and inspiring environment where children and teens can explore, learn, and grow through adventure, creativity, and friendship."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Our Vision",
|
||||||
|
"description": "We aim to be a leading international camp experience that nurtures confident, responsible, and compassionate young individuals connected to nature and their communities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Company Goals",
|
||||||
|
"description": "Through hands-on activities, community service, and outdoor adventures, we help campers build independence, teamwork, and lifelong memories."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"whyChooseUs": {
|
||||||
|
"title": "- Why Choose Us",
|
||||||
|
"subtitle": "Creating unforgettable camp experiences with safety, fun, and friendship.",
|
||||||
|
"description": "Go and Grow Camp has organized international summer camps and educational outdoor trips across multiple countries. We are committed to providing a safe, inclusive, and inspiring environment—no violence, drugs, alcohol, or cigarettes are permitted for any participant.",
|
||||||
|
"button": {
|
||||||
|
"label": "Explore Now",
|
||||||
|
"href": "/booking"
|
||||||
|
},
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"title": "Inclusive & Welcoming",
|
||||||
|
"description": "Every child, teen, and staff member, regardless of country or culture, feels comfortable and valued, creating a unique and unforgettable camp experience."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Adventure-Ready Experiences",
|
||||||
|
"description": "From team challenges to outdoor exploration, creative workshops, and water sports, we offer a wide range of activities that build confidence, teamwork, and independence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Personal Growth & Friendship",
|
||||||
|
"description": "Campers develop life skills, make lifelong friends, and return home more confident, motivated, and inspired."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Safe & Responsible Environment",
|
||||||
|
"description": "Our trained staff ensure every camper enjoys a secure, supportive, and funfilled experience."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Nature-Friendly",
|
||||||
|
"Adventure-Ready",
|
||||||
|
"Community Service",
|
||||||
|
"Inspiring Locations"
|
||||||
|
],
|
||||||
|
"cta": {
|
||||||
|
"text": "Let's make something great work together.",
|
||||||
|
"linkText": "Get Free Quote",
|
||||||
|
"linkHref": "#"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"title": "Surfing Adventures",
|
||||||
|
"description": "Catch the waves and learn water safety while having a blast on the beach.",
|
||||||
|
"image": "/uploads/home/b13.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "River Kayaking",
|
||||||
|
"description": "Paddle along scenic rivers, surrounded by wildlife and stunning landscapes.",
|
||||||
|
"image": "/uploads/home/b14.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Campfire Nights",
|
||||||
|
"description": "Gather around the fire, roast marshmallows, and share stories under the stars",
|
||||||
|
"image": "/uploads/home/b16.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Community Service Projects",
|
||||||
|
"description": "Participate in meaningful activities such as beach clean-ups, tree planting, and helping local community initiatives.",
|
||||||
|
"image": "/uploads/home/b11.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"title": "- Frequently Asked Questions",
|
||||||
|
"subtitle": "Essential Camp Info",
|
||||||
|
"description": "Everything you need to know for a safe, fun, and unforgettable experience. Get quick details about our programs, activities, accommodations, and community projects – all in one place.",
|
||||||
|
"image": "/uploads/home/b5.jpg",
|
||||||
|
"contact": {
|
||||||
|
"title": "Need Any Help?",
|
||||||
|
"info": "+(123) 456-789 | office@ggcamp.org"
|
||||||
|
},
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"question": "Safety & Supervision?",
|
||||||
|
"answer": "Our trained and friendly staff are dedicated to ensuring every camper feels safe and supported throughout their stay. All camp sites are carefully chosen and regularly inspected for safety, and every activity is closely supervised. From water sports to forest hikes, we maintain high safety standards while encouraging campers to explore, challenge themselves, and grow."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Food & Meals?",
|
||||||
|
"answer": "Nutritious and balanced meals are prepared daily to keep campers energized for their activities. From locally sourced ingredients to delicious, kid-friendly recipes, our meals also accommodate special dietary needs. Mealtime is more than just food—it's a time for friends to gather, share experiences, and enjoy new flavors together."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Activities & Adventure?",
|
||||||
|
"answer": "Our diverse program of activities is designed to challenge, inspire, and entertain. Campers can ride the waves during surfing lessons, paddle scenic rivers on kayaking tours, or participate in team challenges and creative workshops. We also include meaningful community service projects, like beach clean-ups and tree planting, to teach responsibility and environmental stewardship. Every activity is a chance to learn, grow, and create lasting memories."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Can I bring my own food or cook at the campsite?",
|
||||||
|
"answer": "Absolutely! Each site has cooking facilities including BBQ grills and fire pits. You're welcome to bring your own food and beverages."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "What types of adventure activities are available?",
|
||||||
|
"answer": "We offer hiking, kayaking, rock climbing, mountain biking, fishing, and guided nature tours. Activities vary by location and season."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "What is the cancellation policy?",
|
||||||
|
"answer": "Cancellation policies vary by location and activity. Some may allow cancellations with a fee, while others may have strict cancellation policies. It's important to review the specific cancellation policy for each activity or location before booking."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "What is the refund policy?",
|
||||||
|
"answer": "Refund policies vary by location and activity. Some may allow refunds with a fee, while others may have strict refund policies. It's important to review the specific refund policy for each activity or location before booking."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"partners": {
|
||||||
|
"title": "- Our Partners",
|
||||||
|
"subtitle": "Working with the best in outdoor living and exploration",
|
||||||
|
"backgroundImage": "/uploads/home/b2.jpg",
|
||||||
|
"logos": [
|
||||||
|
"/uploads/home/anhsims.png",
|
||||||
|
"/uploads/home/anhlogo2.png",
|
||||||
|
"/uploads/home/anhlogo9.png",
|
||||||
|
"/uploads/home/anhlogo4.png"
|
||||||
|
],
|
||||||
|
"cta": {
|
||||||
|
"badge": "Free",
|
||||||
|
"text": "Let's make something great work together.",
|
||||||
|
"linkText": "Get Free Quote",
|
||||||
|
"linkHref": "/booking"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"programs": {
|
||||||
|
"title": "- Activies",
|
||||||
|
"subtitle": "Explore Our Activities",
|
||||||
|
"button": {
|
||||||
|
"label": "Explore Now",
|
||||||
|
"href": "/booking"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"pricePrefix": "from",
|
||||||
|
"priceSuffix": "USD",
|
||||||
|
"buttonLabel": "Camp Detail",
|
||||||
|
"buttonHref": "/camp-profiles"
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "adventure-sports-creative",
|
||||||
|
"title": "Adventure, Sports & Creative",
|
||||||
|
"price": "395",
|
||||||
|
"seasons": ["spring", "summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Thailand",
|
||||||
|
"image": "/uploads/home/b5.jpg",
|
||||||
|
"slug": "adventure-sports-creative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arts-crafts",
|
||||||
|
"title": "Arts & Crafts",
|
||||||
|
"price": "500",
|
||||||
|
"seasons": ["spring", "summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Vietnam",
|
||||||
|
"image": "/uploads/home/b6.jpg",
|
||||||
|
"slug": "arts-crafts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "climbing",
|
||||||
|
"title": "Climbing",
|
||||||
|
"price": "515",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Philippines",
|
||||||
|
"image": "/uploads/home/b1.jpg",
|
||||||
|
"slug": "climbing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dancing",
|
||||||
|
"title": "Dancing",
|
||||||
|
"price": "520",
|
||||||
|
"seasons": ["summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Malaysia",
|
||||||
|
"image": "/uploads/home/b4.jpg",
|
||||||
|
"slug": "dancing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "diving",
|
||||||
|
"title": "Diving",
|
||||||
|
"price": "1190",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Philippines",
|
||||||
|
"image": "/uploads/home/b2.jpg",
|
||||||
|
"slug": "diving"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "englisch-toefl",
|
||||||
|
"title": "Englisch TOEFL®",
|
||||||
|
"price": "1290",
|
||||||
|
"seasons": ["spring", "summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Malaysia",
|
||||||
|
"image": "/uploads/home/b1.jpg",
|
||||||
|
"slug": "englisch-toefl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "englischcamps",
|
||||||
|
"title": "Englischcamps",
|
||||||
|
"price": "530",
|
||||||
|
"seasons": ["spring", "summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Philippines",
|
||||||
|
"image": "/uploads/home/00-Language-Camps-by-Camp-Adventure-add7aa60.jpg",
|
||||||
|
"slug": "englischcamps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fishing",
|
||||||
|
"title": "Fishing",
|
||||||
|
"price": "580",
|
||||||
|
"seasons": ["spring", "summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Vietnam",
|
||||||
|
"image": "/uploads/home/01-Angeln-im-Ferienlager-02243939.jpg",
|
||||||
|
"slug": "fishing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "german-camps",
|
||||||
|
"title": "German Camps",
|
||||||
|
"price": "610",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Thailand",
|
||||||
|
"image": "/uploads/home/Deutschcamps-in-Deutschland-0ed3ea07.jpg",
|
||||||
|
"slug": "german-camps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "horseback-riding",
|
||||||
|
"title": "Horseback Riding",
|
||||||
|
"price": "620",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Portugal",
|
||||||
|
"image": "/uploads/home/00-Reiten-Sommercamp-Ausritt-6930f841.jpg",
|
||||||
|
"slug": "horseback-riding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "husky-camp",
|
||||||
|
"title": "Husky Camp",
|
||||||
|
"price": "525",
|
||||||
|
"seasons": ["spring", "summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "China",
|
||||||
|
"image": "/uploads/home/00-Husky%20Camp_sommercamp%20mit%20Hunden-9c098a17.jpg",
|
||||||
|
"slug": "husky-camp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "icit",
|
||||||
|
"title": "International Counsellor in Training (ICIT)",
|
||||||
|
"price": "995",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Thailand",
|
||||||
|
"image": "/uploads/home/00-INTERNATIONAL%20COUNSELOR%20IN%20TRAINING_teambuilding-3b91547c.jpg",
|
||||||
|
"slug": "international-counsellor-in-training-icit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lifeguarding",
|
||||||
|
"title": "Lifeguarding",
|
||||||
|
"price": "580",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Malaysia",
|
||||||
|
"image": "/uploads/home/00-Rettungsschwimmen-Feriencamp-6a364891.jpg",
|
||||||
|
"slug": "lifeguarding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "leadership",
|
||||||
|
"title": "Leadership",
|
||||||
|
"price": "1185",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Philippines",
|
||||||
|
"image": "/uploads/home/00-Leadership-Camp-0d21c60a.jpg",
|
||||||
|
"slug": "senior-plus-leadership"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "multi-water-adventure",
|
||||||
|
"title": "Multi Water Adventure",
|
||||||
|
"price": "990",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Philippines",
|
||||||
|
"image": "/uploads/home/00-Multi-Water-Adventure-im-Sommercamp-a47c08a3.jpg",
|
||||||
|
"slug": "multi-water-adventure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sailing",
|
||||||
|
"title": "Sailing",
|
||||||
|
"price": "990",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Thailand",
|
||||||
|
"image": "/uploads/home/01-Segeln-im-Sommercamp-in-Spanien-e9d06b28.jpg",
|
||||||
|
"slug": "sailing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "skating",
|
||||||
|
"title": "Skating",
|
||||||
|
"price": "420",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Vietnam",
|
||||||
|
"image": "/uploads/home/00-Skaten%20im%20Sommercamp-8240a4c7.jpg",
|
||||||
|
"slug": "skating"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soccer",
|
||||||
|
"title": "Soccer",
|
||||||
|
"price": "445",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Malaysia",
|
||||||
|
"image": "/uploads/home/00-Soccer-Camps-543a1625.jpg",
|
||||||
|
"slug": "soccer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "space-exploration",
|
||||||
|
"title": "Space Exploration",
|
||||||
|
"price": "665",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "China",
|
||||||
|
"image": "/uploads/home/00-Space-Exploration-Sommer-Camp-599962e5.jpg",
|
||||||
|
"slug": "space-exploration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "spanishcourse",
|
||||||
|
"title": "Spanishcourses",
|
||||||
|
"price": "0",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Portugal",
|
||||||
|
"image": "/uploads/home/Spanischcamp-in-Spanien-d118b0e9.jpg",
|
||||||
|
"slug": "spanishcourse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "survival",
|
||||||
|
"title": "Survival",
|
||||||
|
"price": "560",
|
||||||
|
"seasons": ["spring", "summer", "autumn"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Vietnam",
|
||||||
|
"image": "/uploads/home/00-Survival%20im%20Feriencamp-28694148.jpg",
|
||||||
|
"slug": "survival-camps"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "swimming",
|
||||||
|
"title": "Swimming",
|
||||||
|
"price": "490",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Philippines",
|
||||||
|
"image": "/uploads/home/Schwimmen_camp-00683667.jpg",
|
||||||
|
"slug": "swimming"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tennis",
|
||||||
|
"title": "Tennis",
|
||||||
|
"price": "695",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Malaysia",
|
||||||
|
"image": "/uploads/home/00-Tenniscamp-57cd2c79.jpg",
|
||||||
|
"slug": "tennis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "windsurfing",
|
||||||
|
"title": "Windsurfing",
|
||||||
|
"price": "0",
|
||||||
|
"seasons": ["summer"],
|
||||||
|
"age": "From 12 - 18 years old",
|
||||||
|
"location": "Thailand",
|
||||||
|
"image": "/uploads/home/00-Windsurfen-im-Sommercamp-f9c58dd4.webp",
|
||||||
|
"slug": "windsurfing"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"newsletter": {
|
||||||
|
"title": "Stay Updated with Our Monthly",
|
||||||
|
"subtitle": "Newsletter",
|
||||||
|
"description": "Sign up to receive the latest news about new camps, activities, and exciting opportunities. Don't miss out on anything fun!",
|
||||||
|
"image": "/uploads/home/b10.jpg",
|
||||||
|
"decorativeImage": "/uploads/home/footer-shape.png",
|
||||||
|
"button": {
|
||||||
|
"label": "Subscribe",
|
||||||
|
"placeholder": "Enter your email address",
|
||||||
|
"href": "/booking"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"latestPosts": {
|
||||||
|
"title": "- Your next step",
|
||||||
|
"subtitle": "Read Every News & Blog",
|
||||||
|
"searchPlaceholder": "Search...",
|
||||||
|
"sidebarTitle": "Latest Posts",
|
||||||
|
"blogPosts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"image": "/uploads/home/b1.jpg",
|
||||||
|
"title": "Power of Consistency",
|
||||||
|
"description": "Customized training programs to enhance skills and improve team performance.",
|
||||||
|
"date": "June 30, 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"image": "/uploads/home/b2.jpg",
|
||||||
|
"title": "You Need to Know",
|
||||||
|
"description": "Expert project management ensuring timely delivery and budget compliance.",
|
||||||
|
"date": "June 30, 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"image": "/uploads/home/b3.jpg",
|
||||||
|
"title": "Common Mistakes",
|
||||||
|
"description": "Comprehensive marketing strategies focused on increasing brand awareness and sales.",
|
||||||
|
"date": "June 30, 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"image": "/uploads/home/b4.jpg",
|
||||||
|
"title": "Quality Always Wins",
|
||||||
|
"description": "Innovative design services that bring your creative visions to life.",
|
||||||
|
"date": "June 30, 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"image": "/uploads/home/b5.jpg",
|
||||||
|
"title": "Tips You Should Know",
|
||||||
|
"description": "Reliable customer support designed to provide fast and effective solutions.",
|
||||||
|
"date": "June 30, 2025"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"image": "/uploads/home/b6.jpg",
|
||||||
|
"title": "Make the Most of It",
|
||||||
|
"description": "Professional consulting tailored to meet your unique business challenges and goals.",
|
||||||
|
"date": "May 31, 2025"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sidebarPosts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"image": "/uploads/home/b7.jpg",
|
||||||
|
"title": "Make the Most of It",
|
||||||
|
"description": "Professional consulting tailored to meet your unique business challenges."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"image": "/uploads/home/b8.jpg",
|
||||||
|
"title": "Tips You Should Know",
|
||||||
|
"description": "Reliable customer support designed to provide fast and effective solutions."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"image": "/uploads/home/b1.jpg",
|
||||||
|
"title": "Quality Always Wins",
|
||||||
|
"description": "Innovative design services that bring your creative visions to life."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"featuredCard": {
|
||||||
|
"image": "/uploads/home/b2.jpg",
|
||||||
|
"title": "Comfort Plus",
|
||||||
|
"description": "Expert project management ensuring timely delivery and budget compliance."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
data/insurance.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Insurance & Travel Cancellation Guarantee",
|
||||||
|
"subtitle": "Comprehensive coverage for your peace of mind",
|
||||||
|
"backgroundImage": "/uploads/banner/b13.jpg",
|
||||||
|
"sectionClass": "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
"backgroundClasses": "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
"overlayStyle": {
|
||||||
|
"backgroundColor": "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
"titleClass": "text-white text-[5vw] uk-text-center",
|
||||||
|
"subtitleClass": "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
|
||||||
|
"enableScrollspy": true
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Insurance & Travel Information",
|
||||||
|
"divider": true,
|
||||||
|
"sectionClass": "uk-section-default uk-section-overlap uk-section",
|
||||||
|
"titleClass": "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
"dividerClass": "uk-divider-small uk-text-left@m uk-text-center"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"sectionClass": "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
"textClass": "uk-panel uk-margin text-[1vw]",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"level": 2,
|
||||||
|
"text": "Our Go and Grow Camp Insurance Package"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "Liability, casualty and health insurance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "<strong>Price:</strong> USD 45 per person/trip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "It only takes one mouse-click to book our comprehensive holiday insurance package for travels abroad, which includes a liability, casualty and health insurance for the entire duration of your journey. This ensures that your child is well insured in the unlikely event of an accident, a doctor's visit, a stay at the hospital or a misfortune causing damage to external property."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "The insurance covers the whole duration of the trip, including the days of arrival and departure."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "Please note that all participants without an EU insurance card/private health insurance or without a travel insurance package have to be prepared to cover the costs for medical treatment themselves. Go and Grow Camp does not provide any advance payment for doctor's bills. Non EU residents who do not book our insurance package have to submit a confirmation of their travel insurance."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"level": 2,
|
||||||
|
"text": "Go and Grow Camp Travel Cancellation Guarantee"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "It only takes one mouse-click to book our comprehensive holiday insurance package for travels abroad, which includes a liability, casualty and health insurance for the entire duration of your journey. This ensures that your child is well insured in the unlikely event of an accident, a doctor's visit, a stay at the hospital or a misfortune causing damage to external property."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "The insurance covers the whole duration of the trip, including the days of arrival and departure."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "Please note that all participants without an EU insurance card/private health insurance or without a travel insurance package have to be prepared to cover the costs for medical treatment themselves. Go and Grow Camp does not provide any advance payment for doctor's bills. Non EU residents who do not book our insurance package have to submit a confirmation of their travel insurance."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"level": 2,
|
||||||
|
"text": "Go and Grow Camp - Cooperations & Memberships"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
100
data/menu-header.json
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"menuid": "info",
|
||||||
|
"parent": null,
|
||||||
|
"title": "Info",
|
||||||
|
"url": "#",
|
||||||
|
"order": 0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "info-about-us",
|
||||||
|
"parent": "info",
|
||||||
|
"title": "About us",
|
||||||
|
"url": "/info/about-us",
|
||||||
|
"order": 0,
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "info-safety",
|
||||||
|
"parent": "info",
|
||||||
|
"title": "Safety",
|
||||||
|
"url": "/info/safety",
|
||||||
|
"order": 1,
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "info-faq",
|
||||||
|
"parent": "info",
|
||||||
|
"title": "FAQ",
|
||||||
|
"url": "/info/faq",
|
||||||
|
"order": 2,
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "info-terms-conditions",
|
||||||
|
"parent": "info",
|
||||||
|
"title": "Terms & Conditions",
|
||||||
|
"url": "/info/terms-conditions",
|
||||||
|
"order": 3,
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "info-insurance",
|
||||||
|
"parent": "info",
|
||||||
|
"title": "Insurance",
|
||||||
|
"url": "/info/insurance",
|
||||||
|
"order": 4,
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "info-travel-documents",
|
||||||
|
"parent": "info",
|
||||||
|
"title": "Travel Documents",
|
||||||
|
"url": "/info/travel-documents",
|
||||||
|
"order": 5,
|
||||||
|
"type": "page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "camp-locations",
|
||||||
|
"parent": null,
|
||||||
|
"title": "Camp Locations",
|
||||||
|
"url": "/destinations",
|
||||||
|
"order": 1,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "activities",
|
||||||
|
"parent": null,
|
||||||
|
"title": "Activities",
|
||||||
|
"url": "/activities",
|
||||||
|
"order": 2,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "blog",
|
||||||
|
"parent": null,
|
||||||
|
"title": "Blog",
|
||||||
|
"url": "/blog",
|
||||||
|
"order": 3,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "contact-us",
|
||||||
|
"parent": null,
|
||||||
|
"title": "Contact US",
|
||||||
|
"url": "/contact-us",
|
||||||
|
"order": 4,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menuid": "booking",
|
||||||
|
"parent": null,
|
||||||
|
"title": "Booking",
|
||||||
|
"url": "/booking",
|
||||||
|
"order": 5,
|
||||||
|
"type": "static"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
212
data/safety.json
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Safety",
|
||||||
|
"banner": "/uploads/banner/b13.jpg"
|
||||||
|
},
|
||||||
|
"approach":{
|
||||||
|
"badge": "OUR APPROACH",
|
||||||
|
"title": "Learning, Comfort, and Confidence in Every Step",
|
||||||
|
"description": "Our camp philosophy ensures that every experience is exciting, engaging, and safe. We combine the thrill of outdoor exploration with a secure, well-managed environment where campers can grow, connect, and enjoy every moment.",
|
||||||
|
"imgs":
|
||||||
|
{
|
||||||
|
"img1": "/uploads/safety/pic1.jpg",
|
||||||
|
"img2": "/uploads/safety/pic2.jpg"
|
||||||
|
},
|
||||||
|
"stats":{
|
||||||
|
"count": "1,200+",
|
||||||
|
"label": "Happy Glampers Hosted",
|
||||||
|
"avatars": [
|
||||||
|
"https://i.pravatar.cc/100?img=1",
|
||||||
|
"https://i.pravatar.cc/100?img=5",
|
||||||
|
"https://i.pravatar.cc/100?img=8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"features":[
|
||||||
|
{
|
||||||
|
"text":"Community built on trust and respect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text":"Shared responsibility for a safe environment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text":"Zero tolerance for discrimination or abuse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text":"Staff trained and supervised around the clock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cards":[
|
||||||
|
{
|
||||||
|
"title":"Camp Protection",
|
||||||
|
"content":"Comprehensive measures ensure every camper is safe, including trained staff, strict supervision, and clear emergency protocols throughout their stay."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Peace of Mind",
|
||||||
|
"content":"Parents and campers can feel confident knowing that safety, well-being, and support are prioritized at all times."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"philosophy":{
|
||||||
|
"title":"Go and Grow Camp",
|
||||||
|
"subtitle":"Our Philosophy",
|
||||||
|
"cards":[
|
||||||
|
{
|
||||||
|
"title":"Community",
|
||||||
|
"content":"What is most important for us at camp is the community. We want everyone – participants, teamers and camp directors, no matter from which country or what culture – to have an unforgettable time and every single one of us helps to reach this goal.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Responsibility",
|
||||||
|
"content":"We want everyone to help shape the daily life at camp. Besides playing this of course also includes social coexistence. Together with us your children keep the camp clean. This means cleaning the dishes and wiping the tables after a meals, as well as keeping the camp and sanitary facilities clean and tidying up the tents and huts together. All this of course, in a manner appropriate to the age of your children. This is how we, in shared responsibility, make everybody feel comfortable.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Internationality",
|
||||||
|
"content":"At camp new friendships arise even though some campers live thousands of kilometers apart. Our experienced campers immediately include newcomers because this is what they love camp for – they come to make new friends and meet their fellow camp mates again. After our camp season many parents tell us about mutual visits – some went to France, Spain or Canada. They also tell us about the increased motivation of their children to pay a little more attention to the language lessons at school so conversations at camp next summer become easier.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Log off, get outside",
|
||||||
|
"content":"We want all campers to have a relaxed holiday. Mobile phones are especially counterproductive to reach this goal. Therefore, our camps are mobile-free zones and we would like your children to hand over their phones and all other electronic devices to our teamers on the day of arrival so they can really relax. This also means that your children cannot be reached by phone outside the daily telephone hour which is after lunch.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"No power to drugs",
|
||||||
|
"content":"For legal reasons, as a result of our cooperation with the organization 'No power to drugs' and by our conviction that drugs don't belong into the hands of children and young adults, it is strictly forbidden for all campers to possess or consume any kind of drugs including cigarettes and alcoholic drinks. Non-compliance with this rule will lead to the suspension from camp or even criminal charges. It is our belief that with all our activities and the great atmosphere at camp, we offer much better alternatives anyway!",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Dealing with discrimination",
|
||||||
|
"content":"We would like to point out that we do not accept any form of discrimination, bullying or violence so that all campers can enjoy a happy, relaxed and safe holiday at camp.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"security":{
|
||||||
|
"title":"Go and Grow Camp",
|
||||||
|
"subtitle":"Security Concept",
|
||||||
|
"cards":[
|
||||||
|
{
|
||||||
|
"title":"Background Check",
|
||||||
|
"content":"Every counselor, chef, teamer or helper that enters our camps has to be registrated, complete a background check, as well as have references. That's why parents are only allowed on the camp site on the day of arrival and departure and not during the week. We want to make sure that we have checked and know every adult who is with us at camp.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Education",
|
||||||
|
"content":"Each counselor must complete an almost two-week training course with us, from early in the morning until late in the evening includes so many lessons that the number of hours even corresponds to the basic study in educational sciences. Here we focus on the areas of safety, accident prevention, child psychology and needs as well as the various safety aspects in the field of experiential education.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Crisis Intervention",
|
||||||
|
"content":"If something should happen, it is not only important to provide first aid for the affected person, but also to care for the other children and adolescents. We have a specially trained team for crisis intervention, which then provides immediate care and can thus prevent possible traumatisation due to the experience.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Nightwatch",
|
||||||
|
"content":"All our camps are also supervised at night by the counselors/teamers. On the one hand we want to prevent visitors from coming to the site - which has not happened until today - and on the other hand we want to be there for the children if they wake up at night and get homesick or have to go to the toilet. The nightwatch patrols the area and is otherwise reachable at a central place for the children. Some of our locations - e.g. the headquarters in Walsrode - are also video-monitored and fenced in.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Caregiver Key",
|
||||||
|
"content":"No safety without sufficient staff! We are the leaders in Germany with our great caregiver key. There are no camps that have a key worse than 1:6-1:8, which means that one caregiver is responsible for a maximum of 6-8 children. In the junior camps we also use our CIT (Counselor in Training), so that we often reach a key of only 1:4. We know that this key can seem exaggerated, but we want to guarantee the highest possible safety and we firmly believe that this is exactly what our high level of caregiver commitment leads to.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Cooperation",
|
||||||
|
"content":"Cooperation with the independent representative for questions of sexual child abuse via our umbrella organisation Reisenetz e.V.: Go and Grow Camp was one of the first tour operators for children and young people to develop a protection concept that prevents sexual abuse among children and young people. Today, this concept is considered important by many other tour operators, also due to our personal commitment in various associations and professional circles. Of course, the background check and the '6-eyes principle', which states that a child must never be alone with a caregiver, is also an essential part of our protection concept. The most important thing, however, is to create an 'open system' in which everyone knows that sexual abuse should not be a taboo subject, but that simple instruments such as a grievance box and feedback system can immediately address grievances and that they do not have to be denied.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Quality",
|
||||||
|
"content":"As a member of the quality committee of the professional association for children and youth travel 'Reisenetz', our managing director Jan Vieth is responsible for further developing and checking the quality guidelines of the entire industry. As Germany's ambassador to the ICF, he is also kept up to date on improvements in camp and training quality worldwide and adapts these as quickly as possible to our own camps.",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"Accessibility",
|
||||||
|
"content":"Of course, all parents receive a number from us, which allows them to reach us 24 hours a day in an emergency. If an emergency occurs at your home, you can inform us immediately and we can decide together how, when and whether to inform your child",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title":"In case of emergency",
|
||||||
|
"content":"Every caregiver has a valid first aid certificate and can help if necessary",
|
||||||
|
"author":{
|
||||||
|
"avt":"https://i.pravatar.cc/150?img=12",
|
||||||
|
"name":"abc",
|
||||||
|
"role":"customer",
|
||||||
|
"rating":"5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
152
data/terms-conditions.json
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Frequently Asked Questions",
|
||||||
|
"backgroundImage": "/uploads/terms/faqimage.jpg",
|
||||||
|
"sectionClass": "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
"backgroundClasses": "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
"overlayStyle": {
|
||||||
|
"backgroundColor": "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
"titleClass": "text-white text-[5vw] uk-text-center",
|
||||||
|
"enableScrollspy": true
|
||||||
|
},
|
||||||
|
|
||||||
|
"page": {
|
||||||
|
"title": "Terms & Conditions Go and Grow Camp e.K.",
|
||||||
|
"divider": true,
|
||||||
|
"sectionClass": "uk-section-default uk-section-overlap uk-section",
|
||||||
|
"titleClass": "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
"dividerClass": "uk-divider-small uk-text-left@m uk-text-center"
|
||||||
|
},
|
||||||
|
|
||||||
|
"content": {
|
||||||
|
"sectionClass": "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
"textClass": "uk-panel uk-margin text-[1vw]",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "This is an English translation of the original and legally binding German document \"Allgemeine Geschäftsbedingungen Go and Grow Camp e.K.\", which can be viewed at <a href=\"https://www.campadventure.de/de/infos/agb\" target=\"_self\">https://www.campadventure.de/de/infos/agb</a>. This translation is for your information only and is not legally binding."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "<strong>Go and Grow Camp e.K. is the tour operator for individuals, for camps in Germany, England and Northern Ireland.</strong>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "GUARANTEE: All participants are protected in accordance with the legal regulations governing tour operators in Germany. As per §651, any payments made towards the travel price are insured against insolvency by tourVers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "The following terms and conditions of travel apply to package travel contracts, to which the §§ 651a ff BGB regulations relating to travel contracts apply. The provisions, in so far as these have been effectively agreed, become part of the contract formed between the traveler and tour operator. They supplement and complete the legal regulations of §§ 651 a to y BGB and Articles 250 und 252 EGBGB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "1. Conclusion of the travel contract",
|
||||||
|
"content": "By registering for travel, the traveler submits a binding offer to conclude the travel agreement. Registrations can be made verbally, by telephone, in writing, by email or by electronic means, such as the internet booking system \"Book a Camp\". The contract comes into effect once a declaration of acceptance has been received. The tour operator will provide the traveler with a booking confirmation in line with legal requirements in a durable medium, unless the traveler is entitled to a travel confirmation in paper form under Article 250 § 6 Paragraph 1 Clause 2 EGBG. If the registration is made electronically, the contract is concluded once the traveler has received confirmation from the tour operator in a durable medium. If the corresponding travel confirmation is displayed directly after using the \"place a binding order\" button, the contract comes into effect upon display of this confirmation. The traveler will receive travel documents 2-3 weeks before the start of the trip. Any additional agreements, arrangements and wishes must be confirmed by us in writing, otherwise the services laid out in the contract apply. The traveler is liable for all contractual obligations of travelers that he registers, just as he is for his own, provided that he has assumed this obligation through an explicit and separate declaration. Should the contents of the booking confirmation deviate from the content of the booking, this constitutes a new offer, to which the tour operator is bound for a period of 10 days. The contract takes effect on the basis of this new offer, provided that the tour operator has indicated the changes relating to this new offer and has fulfilled his precontractual information duties and that the traveler gives the tour operator express consent, either through explicit declaration or deposit, within the commitment period. Pursuant to the legal regulation § 312 g Para. 2, Clause 1 Nr. 9 BGB and relating to all of the above-mentioned booking types, no right of withdrawal exists for distance contracts after contract conclusion. However, withdrawal from the contract on the basis of § 651 h BGB is possible at any time."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "2. Terms of payment",
|
||||||
|
"content": "Go and Grow Camp e.K. shall only request or accept payments towards the travel price before the completion of the trip if the traveler has been provided with a guarantee certificate, stating the name and contact details of the credit institution, in accordance with § 651 r Abs. 4 BGB. A deposit of USD 50 per participant is due within one week of registration and after the issue of a guarantee certificate. The outstanding balance must be transferred, without specific request, no later than four weeks before the start of the trip, provided that the guarantee certificate has been issued and that the tour operator has not exercised its right of withdrawal on the grounds stated in Point 7. If, even after notification, the specified deposit sum is not payed, or the travel price has not been paid in full, prior to the commencement of the trip, although the tour operator is ready to provide the contractual services, has fulfilled all legal obligations and the client has no legal or contractual right of retention, the tour operator is entitled to withdraw from the travel contract after issuing a reminder with a deadline and to charge cancellation fees to the traveler."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "3. Services and service modifications",
|
||||||
|
"content": "a) Our services are defined in our service descriptions and general program information found on the website <a href=\"/\" target=\"_self\">https://www.campadventure.de/en/</a> and in the information given in the travel confirmation. Any additional agreements affecting the scope of the contractual services must be confirmed by us in written form.<br/>b) Luggage will be transported without any additional fee, as long as it does not exceed the norms, here defined as a maximum of 1 suitcase and 1 piece of hand luggage per person.<br/>c) External services arranged by us as part of the journey are not part of the initial travel contract, as long as these services are clearly marked as such with the identity and address of the contractual partner in the travel information and travel confirmation, such that the traveler can recognize that these are not part of the travel services offered by the tour operator.<br/>d) Any modifications to and deviations from the essential travel services agreed upon in the travel contract that become necessary after conclusion of the contract and are made in good faith, are permissible as long as the modifications and deviations are not substantial and do not impact the overall arrangement of the booked trip.<br/>e) The tour operator is obliged to inform the traveler of the reasons for a permissible modification to the essential travel service immediately, clearly, understandably and in a durable medium.<br/>f) In the event of a substantial change to an essential travel service or a deviation from special provisions stipulated in the contract for a traveler, the traveler is entitled to withdraw from the contract or demand another journey of at least equivalent value by the deadline specified at the same time as the contract change. This only applies if the tour operator is in a position to offer such a trip without any extra cost to the traveler. The traveler is free to decide whether to respond to the communication or not. The traveler is obliged to exercise these rights after being notified of the change. If the traveler does not respond by the specified deadline or at all, the communicated changes will be understood to be accepted. Any warranty claims remain unaffected, in so far as the modified services are deficient."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "4. Customer cancellation",
|
||||||
|
"content": "The traveler is advised to communicate cancellation in a durable medium. Should the traveler withdraw from the travel contract before the start of the trip, or should he not begin the trip, the tour operator may claim fair compensation, provided it is not responsible for the withdrawal and that no exceptional circumstances have arisen at the destination or in the immediate vicinity, which have a significant effect on the execution of the trip or the transportation of persons to the destination. The compensation value is based on the travel price less the value of the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of its services. The standard rates are based on the time period between the notice of cancellation and the start of the trip, as well as the expected saved expenses and the possible sum resulting from any other use of travel services. Upon receipt of notice of cancellation, compensation is calculated according to a sliding percentage scale, as follows (cancellation costs per person):",
|
||||||
|
"subsections": [
|
||||||
|
{
|
||||||
|
"type": "cancellation_table",
|
||||||
|
"title": "Standard Cancellation Fees",
|
||||||
|
"items": [
|
||||||
|
"cancellation up to 60 days before the beginning of the trip – USD 50/100",
|
||||||
|
"cancellation up to 31 days before the beginning of the trip – 30% of travel costs, USD 50 minimum",
|
||||||
|
"cancellation up to 14 days before the beginning of the trip – 50% of travel costs, USD 50 minimum",
|
||||||
|
"cancellation up to 1 day before the beginning of the trip – 80% of travel costs, USD 50 minimum",
|
||||||
|
"cancellation on the day of arrival or later – 90% of travel costs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cancellation_section",
|
||||||
|
"title": "Cancellation policy for school groups:",
|
||||||
|
"items": [
|
||||||
|
"A correction of student numbers up to 10% students is free of charge. Any higher alteration of numbers will lead to an extra cost.",
|
||||||
|
"Cancellation till 60 days before start of the trip: the fee will be 20% of the total price.",
|
||||||
|
"Cancellation till 30 days before start of the trip: the fee will be 40% of the total price.",
|
||||||
|
"Cancellation till 14 days before start of the trip: the fee will be 60% of the total price.",
|
||||||
|
"Cancellation till 1 day before start of the trip: the fee will be 90% of the total price.",
|
||||||
|
"Any later cancellations till the day before the trip: the fee will be 100% of the total price."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "note",
|
||||||
|
"text": "In any event, it is up to the customer to demonstrate that compensation owed to the tour operator is significantly lower that the cancellation fee claimed. The tour operator reserves the right, by way of deviation from the above charges, to claim a higher, individually calculated compensation sum, insofar as it can prove that significantly greater expenses than the relevant flat rate were incurred. In this case, the tour operator is required to calculate and prove these extra costs, taking into account the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of the services. Following cancellation, the tour operator is obliged to issue a refund immediately, but in any case within 14 days of receipt of the notice of cancellation. § 651 e BGB remains unaffected by the above conditions. It is recommended that travelers take out cancellation insurance."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "5. Modifications at the traveler's request",
|
||||||
|
"content": "After conclusion of the contract the traveler may not change travel dates, the destination, starting location, accommodation or mode of transport. This does not apply if the change to the booking is necessary because the tour operator provided the traveler due to inadequate or false precontractual information provided by the tour operator, as per Art. 250 § 3 EGBGB. In this case, travel may be rebooked at no extra cost. Should the traveler demand changes or rebooking after conclusion of the contract, up to 32 days before departure, the tour operator is entitled to charge a processing fee of USD 20, unless the tour operator demonstrates that higher compensation is due, the sum of which is based on the travel price minus the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of its services. Requests to change bookings after this period can only be honored, if at all, by withdrawing from the travel contract and simultaneously reregistering, as per Section 4. This does not apply to requests only resulting in minor additional costs."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "6. Disruption by the traveler",
|
||||||
|
"content": "If the traveler continuously disrupts the travel program, despite warnings from the tour operator, or behaves contrary to the contract, such that immediate termination of the contract is justified, the tour operator may cancel the travel contract without notification. This also applies when the traveler does not consider reasonable and well-founded instructions. In such cases, the tour operator is entitled to retain the full travel price, minus the costs saved by the tour operator and the sum that the tour operator is able to earn through alternative use of the unused service, including any sums credited to it by service providers, so the daily rate can be reduced by 20% as a result of savings made by services not provided. Compensation claims remain unaffected. This shall not apply if such behavior contrary to the terms of the contract is a result of a breach of information duties on the part of the tour operator."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "7. Minimum number of participants",
|
||||||
|
"content": "If the number of participants registered for our holiday camps our transfer services is less than 10-60 participants (depending on the trip), the tour operator may withdraw from the travel contract up to 6 weeks before the start of the trip. The tour operator must have stated the minimum number of participants for the relevant trip and the latest date by which the traveler must be informed of cancellation in the travel information and must also have clearly stated the minimum number of participants and the latest possible date of withdrawal in the travel confirmation. If it is evident at an earlier stage that the minimum number of participants will not be reached, the tour operator is obliged to inform the traveler immediately. If the trip does not take place for this reason, the tour operator is obliged to issue a refund of any payments made on the travel price immediately and in any case within 14 days of notice of withdrawal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "8. Warranty and remedy",
|
||||||
|
"content": "Should services not be rendered according to the contract, the traveler is entitled to claim legal warranty rights for a reduction in the trip price, according to § 651 m BGB, provided that the traveler has not failed in his contractual duties to report any faults to the tour operator which may have occurred during the provision of services. In the event of a defect during the tour, the traveler can only remedy the defect himself or, in the case of a considerable defect, as described in § 651 i Abs. 2 BGB, cancel the trip, according to § 651 l BGB, as long as the tour operator has been given an adequate time to remedy the defect. A deadline need not be defined if remedial action is impossible or rejected by the tour operator or if immediate remedial action or termination is justified due to particular interests of the client. The traveler is obliged to inform the tour operator of any defect immediately and on the spot. Defects should be reported to the tour manager of the tour operator, to the contact person at the contact address or the tour operator directly. Should a representative of the tour operator not be available or contractually obliged, the tour operator must be informed of any defects relating to the trip at the following address: Go and Grow Camp e.K., Museumstr. 39, 22765 Hamburg. It is recommended that such notifications are made in a durable medium. In accordance with § 651 j BGB, claims shall lapse two years after the final day of the trip, as defined by the contract. We refer to the mutual assistance clause under § 651 q BGB, according to which the traveler is entitled to adequate assistance, notably through the provision of appropriate information concerning healthcare services, local authorities and consular assistance, as well as support in establishing communication links and in the search for other travel options, without delay in the event of § 651 k Para. 4 BGB or if the traveler faces difficulties for other reasons. § 651 k Para. 3 BGB remains unaffected."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "9. Traveler's duty of cooperation",
|
||||||
|
"content": "The passenger is obliged to cooperate within the framework of legal regulations and to avoid or minimize potential damages. In the case of travel involving minors, it is the person with the supervisory role and not the tour operator, who is liable for any damages that arise. A violation of regulations may result in exclusion from the trip, as stipulated in Point 6 \"Disruption by the traveler\". Destruction, loss, damage or delay of baggage must be communicated to the transport company immediately. The transport company is required to issue written confirmation. In the case of no notification, there is a danger of losing the right to claims. The tour operator recommends that damage or delay in delivery when travelling by air is urgently and immediately reported to the relevant airline on the spot by means of a property irregularity report (P.I.R.). As a rule, airlines refuse to provide compensation if a property irregularity report has not been completed. The property irregularity report must be submitted within 7 days for lost luggage and within 21 days of delivery of delayed luggage. Otherwise, loss, damage or misdirection of baggage must be reported to the tour operator or to the local representative of the operator. This does not release the traveler from providing the airline with a property irregularity report within the above-mentioned periods."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "10. Limitation of liability",
|
||||||
|
"content": "The tour operator's contractual liability for damages, not including damage to the body, nor damage caused by the negligence of the tour operator, is limited to three times the tour price. Any claims under international agreements or on legal regulations based on these remain unaffected by this limitation. We are not liable for service disruptions, personal injury or property damage in connection with third party services that are explicitly designated as such in the travel description and travel confirmation, where the name and address of the contract partner are given, in such a way that the traveler can clearly recognize that these are not an integral part of the travel services offered by the tour operator and that these are chosen separately. This applies in particular to additional programs over the course of the trip. §§ 651 b, 651 c, 651 w und 651 y remain unaffected. The tour operator is however liable if and insofar as the traveler suffers damages as a result of the failure of the tour operator to fulfill its information, clarification and organization obligations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "11. Passport, visa and health requirements",
|
||||||
|
"content": "The tour operator will inform the customer of any important changes to the general regulations contained in the travel announcement before the start of the trip. Before conclusion of the contract, the tour operator will inform the traveler of visa requirements and health formalities applicable to the destination country, including approximate periods for obtaining the necessary visa and will inform the traveler of any changes to these before the start of the trip. The tour operator shall not be liable for the timely issue and acquisition of necessary visas from the relevant diplomatic representation, if the traveler has charged the tour operator with the procurement of visas, unless the tour operator neglected its duties or is responsible for the delay. The traveler is responsible for compliance with all regulations important for the operation of the tour. The traveler is responsible for obtaining and carrying the necessary travel documents, any necessary vaccinations and for adhering to customs and foreign exchange regulations. Any disadvantages arising from failure to comply with these regulations, including but not limited to the payment of cancellation fees, shall be at the traveler's cost. This does not apply if the tour operator has not provided information, or if the information provided proves to be insufficient or false."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "12. Data protection",
|
||||||
|
"content": "The protection of clients' privacy and personal data is very important to Go and Grow Camp. Go and Grow Camp collects and processes data according to legal regulations. Personal data is only stored when necessary for the performance of booked services or to comply with legal regulations."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "13. Place of jurisdiction",
|
||||||
|
"content": "The entire legal and contractual relationship between the travel operator and travelers with no general place of residence or registered office in Germany shall be governed exclusively by German law, on the proviso that, should the traveler have a general place of residence in another country in accordance with Art. 6 Para. 2 of the Rome I Regulation, they are also protected by any mandatory rules of law in that country, which would not otherwise apply. The traveler can take legal action against the tour operator only at its registered office. Should the travel operator take legal action against the traveler, the domicile of the traveler is decisive, unless action is directed against registered traders or persons who have changed their residence or customary place of abode to a foreign country or whose residence or customary place of abode is not known at the time when legal action is brought. In such cases, the registered office of the tour operator is decisive. With respect to the law concerning consumer dispute resolution, the tour operator advises that it will not take part in any voluntary dispute settlement. Should the tour operator be obliged to take part in a dispute settlement after the printing of these travel conditions, the tour operator will inform the traveler of this in appropriate form. In relation to all travel contracts concluded electronically, the tour operator refers to the European online dispute resolution platform <a href=\"http://ec.europa.eu/consumers/odr/\">http://ec.europa.eu/consumers/odr/</a>."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "14. Identity of the operating airline",
|
||||||
|
"content": "Should the travel contract include transport by plane, the traveler will be informed of the identity and name(s) of the operating airline(s) providing all air transport services as part of the booked trip. Should the identity of the airline(s) be undetermined at the time of booking, the tour operator will inform the traveler of the airline or airlines that are most likely to operate the flight or flights and will inform the traveler immediately, as soon as this is determined. The tour operator must inform the traveler immediately if the airline is changed. The tour operator must take all appropriate steps to ensure that the customer is informed of the change as quickly as possible. The list of airlines on the EU blacklist can be found here: <a href=\"https://ec.europa.eu/transport/modes/air/safety/air-ban/search_en\" target=\"_blank\" rel=\"noopener noreferrer\">https://ec.europa.eu/transport/modes/air/safety/air-ban/search_en</a>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"title": "15. Invalidity of individual terms",
|
||||||
|
"content": "The invalidity of individual terms does not render other conditions or the contract as a whole invalid. 16. VAT Exemption in accordance with § 4 Nr. 23 UstG, Go and Grow Camp e.K. is exempt from sales tax for all child and youth travel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": "Last updated: August 2018"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
34
data/travel.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"hero": {
|
||||||
|
"title": "Go and Grow Camp\nLast travel informations",
|
||||||
|
"backgroundImage": "/uploads/banner/b18.jpg"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "blog",
|
||||||
|
"title": "Go and Grow Camp - Travel",
|
||||||
|
"year": "2026"
|
||||||
|
},
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"id": "travel-info-2026",
|
||||||
|
"title": "Travel Information — Go and Grow Camp 2026",
|
||||||
|
"slug": "travel-information-2026",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"author": "Go and Grow Camp",
|
||||||
|
"excerpt": "Summary of important travel details, arrival/departure times and contact points.",
|
||||||
|
"coverImage": "/uploads/banner/b18.jpg",
|
||||||
|
"categories": ["Travel", "Info"],
|
||||||
|
"tags": ["travel", "camp", "2026"],
|
||||||
|
"content": {
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"data": {
|
||||||
|
"text": "Our entire team is looking forward to an exciting and adventurous holiday camp with you. Below you will find a summary of all the important information about our adventure, sports and language camps. If you have any further questions, please contact us at office@campadventure.de"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app_cms_ggcamp:
|
||||||
|
build: .
|
||||||
|
container_name: my_node_app_cms_ggcamp
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
volumes:
|
||||||
|
- .:/home/node/app
|
||||||
|
- /home/node/app/node_modules
|
||||||
|
network_mode: host
|
||||||
9
middleware/auth.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
ensureAuthenticated: function(req, res, next) {
|
||||||
|
if (req.session.isAuthenticated) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
req.flash('error_msg', 'Please login to access this page');
|
||||||
|
res.redirect('/auth/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
162
middleware/upload.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
// Cấu hình storage cho multer
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
try {
|
||||||
|
// Lấy loại ảnh từ request fields
|
||||||
|
const imageType = req.query.imageType || 'general';
|
||||||
|
|
||||||
|
// Tạo đường dẫn đến thư mục lưu trữ
|
||||||
|
const uploadPath = path.join(__dirname, '../public/uploads', imageType);
|
||||||
|
|
||||||
|
console.log('Creating upload directory:', uploadPath);
|
||||||
|
|
||||||
|
// Kiểm tra và tạo thư mục nếu chưa tồn tại
|
||||||
|
if (!fs.existsSync(uploadPath)) {
|
||||||
|
fs.mkdirSync(uploadPath, { recursive: true });
|
||||||
|
console.log('Directory created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, uploadPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating directory:', error);
|
||||||
|
cb(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
try {
|
||||||
|
const imageType = req.query.imageType || 'general';
|
||||||
|
const uploadPath = path.join(__dirname, '../public/uploads', imageType);
|
||||||
|
|
||||||
|
// Lấy tên file gốc (sanitize để tránh ký tự đặc biệt)
|
||||||
|
const originalName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||||
|
const fullPath = path.join(uploadPath, originalName);
|
||||||
|
|
||||||
|
// Kiểm tra nếu file đã tồn tại
|
||||||
|
if (fs.existsSync(fullPath)) {
|
||||||
|
console.log('File already exists, reusing:', originalName);
|
||||||
|
// Đánh dấu là file đã tồn tại
|
||||||
|
req.fileAlreadyExists = true;
|
||||||
|
req.existingFileName = originalName;
|
||||||
|
cb(null, originalName);
|
||||||
|
} else {
|
||||||
|
// File chưa tồn tại, tạo tên mới
|
||||||
|
console.log('Creating new file:', originalName);
|
||||||
|
cb(null, originalName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in filename function:', error);
|
||||||
|
// Fallback: tạo tên unique nếu có lỗi
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lọc file chỉ cho phép ảnh
|
||||||
|
const fileFilter = (req, file, cb) => {
|
||||||
|
const allowedTypes = /jpeg|jpg|png|gif|webp|svg/;
|
||||||
|
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
const mimetype = allowedTypes.test(file.mimetype);
|
||||||
|
|
||||||
|
if (extname && mimetype) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed!'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cấu hình upload
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 }, // Giới hạn 50MB
|
||||||
|
fileFilter: fileFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cấu hình storage cho video
|
||||||
|
const videoStorage = multer.diskStorage({
|
||||||
|
destination: function (req, file, cb) {
|
||||||
|
// Tạo đường dẫn đến thư mục lưu trữ video
|
||||||
|
const uploadPath = path.join(__dirname, '../public/uploads/videos');
|
||||||
|
|
||||||
|
// Kiểm tra và tạo thư mục nếu chưa tồn tại
|
||||||
|
if (!fs.existsSync(uploadPath)) {
|
||||||
|
fs.mkdirSync(uploadPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, uploadPath);
|
||||||
|
},
|
||||||
|
filename: function (req, file, cb) {
|
||||||
|
// Tạo tên file duy nhất bằng cách thêm timestamp
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, 'video-' + uniqueSuffix + ext);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lọc file chỉ cho phép video
|
||||||
|
const videoFileFilter = (req, file, cb) => {
|
||||||
|
const allowedTypes = /mp4|webm/;
|
||||||
|
const mimetype = allowedTypes.test(file.mimetype);
|
||||||
|
|
||||||
|
if (mimetype) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only mp4 and webm video files are allowed!'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cấu hình upload video
|
||||||
|
const uploadVideo = multer({
|
||||||
|
storage: videoStorage,
|
||||||
|
limits: { fileSize: 300 * 1024 * 1024 }, // Giới hạn 300MB cho video
|
||||||
|
fileFilter: videoFileFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware để convert ảnh sau khi multer lưu xong
|
||||||
|
|
||||||
|
async function convertToWebp(req, res, next) {
|
||||||
|
if (!req.file) return next();
|
||||||
|
console.log('🔄 Converting image to webp format...');
|
||||||
|
console.log('Original file:', req.file.path);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = req.file.path;
|
||||||
|
const webpPath = filePath.replace(path.extname(filePath), '.webp');
|
||||||
|
|
||||||
|
console.log('Converting to:', webpPath);
|
||||||
|
|
||||||
|
await sharp(filePath)
|
||||||
|
.webp({ quality: 80 })
|
||||||
|
.toFile(webpPath);
|
||||||
|
|
||||||
|
// Xóa file gốc sau khi convert thành công
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cập nhật thông tin file
|
||||||
|
req.file.filename = path.basename(webpPath);
|
||||||
|
req.file.path = webpPath;
|
||||||
|
|
||||||
|
console.log('Image converted successfully');
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error converting image:', err);
|
||||||
|
console.error('Error details:', err.message);
|
||||||
|
// Nếu convert thất bại, vẫn tiếp tục với file gốc
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upload,
|
||||||
|
uploadVideo,
|
||||||
|
convertToWebp
|
||||||
|
};
|
||||||
64
models/about.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const aboutSchema = new mongoose.Schema({
|
||||||
|
banner: {
|
||||||
|
image: String,
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: String,
|
||||||
|
paragraphs: [String],
|
||||||
|
list_items: [String],
|
||||||
|
button: {
|
||||||
|
text: String,
|
||||||
|
url: String
|
||||||
|
},
|
||||||
|
image: String,
|
||||||
|
quote: {
|
||||||
|
mark_image: String,
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
author: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
background_image: String,
|
||||||
|
items: [{
|
||||||
|
icon: String,
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
education: {
|
||||||
|
images: {
|
||||||
|
student1: String,
|
||||||
|
student2: String
|
||||||
|
},
|
||||||
|
subtitle: String,
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
},
|
||||||
|
advantages: {
|
||||||
|
title: String,
|
||||||
|
items: [{
|
||||||
|
number: String,
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
academic_board: {
|
||||||
|
title: String,
|
||||||
|
members: [{
|
||||||
|
image: String,
|
||||||
|
title: String,
|
||||||
|
name: String,
|
||||||
|
color: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
updatedAt: Date
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('About', aboutSchema);
|
||||||
88
models/aboutUs.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const aboutUsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
// Hero section
|
||||||
|
hero: {
|
||||||
|
title: String,
|
||||||
|
backgroundImage: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Introduction section with nested services
|
||||||
|
introduction: {
|
||||||
|
subtitle: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
mainImage: String,
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Statistics with nested items
|
||||||
|
statistics: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
number: String,
|
||||||
|
description: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Accommodation section with nested features
|
||||||
|
accommodation: {
|
||||||
|
subtitle: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
icon: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Activities section with nested gallery
|
||||||
|
activities: {
|
||||||
|
subtitle: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
image: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Newsletter
|
||||||
|
newsletter: {
|
||||||
|
imagePath: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
buttonText: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Events with nested items
|
||||||
|
events: {
|
||||||
|
title: String,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
imageUrl: String,
|
||||||
|
date: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
age: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{timestamps: true}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model("AboutUs", aboutUsSchema);
|
||||||
194
models/activity.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const activitySchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
// Hero section for activity page header (supports Activities and Booking variants)
|
||||||
|
hero: {
|
||||||
|
titleActivities: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
titleBooking: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
bannerImageActivities: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
bannerImageBooking: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
priceText: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
season: [
|
||||||
|
{
|
||||||
|
type: String,
|
||||||
|
enum: ["spring", "summer", "autumn", "winter"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
age: {
|
||||||
|
type: [Number],
|
||||||
|
validate: {
|
||||||
|
validator: function (v) {
|
||||||
|
return v.length === 2 && v[0] <= v[1];
|
||||||
|
},
|
||||||
|
message: "Age must be an array of [minAge, maxAge]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
// Global filters document (single document in Activity collection)
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
label: { type: String, required: true, trim: true },
|
||||||
|
value: { type: String, required: true, trim: true },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
value: { type: String, required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: { type: Number, default: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
program: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
type: Number,
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
default: 4,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
// marker for the single document that stores global filters
|
||||||
|
isFiltersDoc: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// Rich camp details from camp-detail field in activities.json
|
||||||
|
campDetail: {
|
||||||
|
type: mongoose.Schema.Types.Mixed,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
// Booking sessions - các đợt booking với thông số riêng
|
||||||
|
bookingSessions: [
|
||||||
|
{
|
||||||
|
sessionId: { type: String, required: true },
|
||||||
|
startDate: { type: Date, required: true },
|
||||||
|
endDate: { type: Date, required: true },
|
||||||
|
overnightStays: { type: Number, required: true, default: 14 },
|
||||||
|
// Spots theo giới tính
|
||||||
|
totalMaleSpots: { type: Number, default: 25 },
|
||||||
|
totalFemaleSpots: { type: Number, default: 25 },
|
||||||
|
bookedMaleSpots: { type: Number, default: 0 },
|
||||||
|
bookedFemaleSpots: { type: Number, default: 0 },
|
||||||
|
price: { type: Number },
|
||||||
|
isActive: { type: Boolean, default: true },
|
||||||
|
// Danh sách booking cho session này
|
||||||
|
bookingList: [
|
||||||
|
{
|
||||||
|
address: { type: String, required: true },
|
||||||
|
agreeNewsletter: { type: Boolean, default: false },
|
||||||
|
agreeTerms: { type: Boolean, required: true },
|
||||||
|
city: { type: String, required: true },
|
||||||
|
country: { type: String, required: true },
|
||||||
|
dietaryRestrictions: {
|
||||||
|
type: String,
|
||||||
|
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
|
||||||
|
default: 'none'
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
lowercase: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
emergencyContact: { type: String, required: true },
|
||||||
|
emergencyPhone: { type: String, required: true },
|
||||||
|
medicalConditions: { type: String, default: '' },
|
||||||
|
numberOfParticipants: { type: Number, required: true, min: 1 },
|
||||||
|
parentFirstName: { type: String, required: true, trim: true },
|
||||||
|
parentLastName: { type: String, required: true, trim: true },
|
||||||
|
participantBirthDate: { type: Date, required: true },
|
||||||
|
participantFirstName: { type: String, required: true, trim: true },
|
||||||
|
participantGender: {
|
||||||
|
type: String,
|
||||||
|
enum: ['male', 'female', 'other'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
participantLastName: { type: String, required: true, trim: true },
|
||||||
|
phone: { type: String, required: true },
|
||||||
|
postalCode: { type: String, required: true },
|
||||||
|
sessionDate: { type: String, required: true }, // sessionId reference
|
||||||
|
specialRequests: { type: String, default: '' },
|
||||||
|
// Thêm các trường quản lý
|
||||||
|
bookingStatus: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'confirmed', 'cancelled', 'completed'],
|
||||||
|
default: 'pending'
|
||||||
|
},
|
||||||
|
paymentStatus: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'partial', 'paid', 'refunded'],
|
||||||
|
default: 'pending'
|
||||||
|
},
|
||||||
|
totalAmount: { type: Number, default: 0 },
|
||||||
|
paidAmount: { type: Number, default: 0 },
|
||||||
|
bookingDate: { type: Date, default: Date.now },
|
||||||
|
confirmationCode: { type: String, unique: true },
|
||||||
|
adminNotes: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{timestamps: true}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add index for better query performance
|
||||||
|
activitySchema.index({name: 1});
|
||||||
|
activitySchema.index({isActive: 1, order: 1});
|
||||||
|
activitySchema.index({season: 1});
|
||||||
|
activitySchema.index({locations: 1});
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Activity", activitySchema);
|
||||||
106
models/booking.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
if (mongoose.models.Booking) {
|
||||||
|
delete mongoose.models.Booking;
|
||||||
|
}
|
||||||
|
if (mongoose.connection.models.Booking) {
|
||||||
|
delete mongoose.connection.models.Booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
hero: {
|
||||||
|
title: String,
|
||||||
|
backgroundImage: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
searchBar: {
|
||||||
|
locationLabel: String,
|
||||||
|
holidaySeasonLabel: String,
|
||||||
|
searchButtonText: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
filterPanel: {
|
||||||
|
title: String,
|
||||||
|
priceTitle: String,
|
||||||
|
priceLabel: String,
|
||||||
|
pricePlaceholder: String,
|
||||||
|
priceMin: Number,
|
||||||
|
priceMax: Number,
|
||||||
|
activitiesTitle: String,
|
||||||
|
ageTitle: String,
|
||||||
|
ageSelectPlaceholder: String,
|
||||||
|
ageMin: Number,
|
||||||
|
ageMax: Number,
|
||||||
|
ratingTitle: String,
|
||||||
|
ratingOptions: [
|
||||||
|
{
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resetButtonText: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
programs: [
|
||||||
|
{
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
holidays: [
|
||||||
|
{
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
camps: [
|
||||||
|
{
|
||||||
|
name: String,
|
||||||
|
price: Number,
|
||||||
|
priceText: String,
|
||||||
|
season: [String],
|
||||||
|
age: [Number],
|
||||||
|
locations: [String],
|
||||||
|
image: String,
|
||||||
|
link: String,
|
||||||
|
program: String,
|
||||||
|
rating: Number,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Configuration - Dùng Mixed type để chấp nhận bất kỳ structure nào
|
||||||
|
configuration: mongoose.Schema.Types.Mixed,
|
||||||
|
|
||||||
|
formSteps: [
|
||||||
|
{
|
||||||
|
step: Number,
|
||||||
|
title: String,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: String,
|
||||||
|
fields: [mongoose.Schema.Types.Mixed],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
validation: mongoose.Schema.Types.Mixed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
strict: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Booking", bookingSchema);
|
||||||
200
models/bookingSubmission.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
if (mongoose.models.BookingSubmission) {
|
||||||
|
delete mongoose.models.BookingSubmission;
|
||||||
|
}
|
||||||
|
if (mongoose.connection.models.BookingSubmission) {
|
||||||
|
delete mongoose.connection.models.BookingSubmission;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingSubmissionSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
// Liên kết với activity và session
|
||||||
|
activityId: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Activity',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
sessionId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thông tin người đăng ký
|
||||||
|
parentFirstName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
parentLastName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
lowercase: true
|
||||||
|
},
|
||||||
|
phone: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thông tin địa chỉ
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
city: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
postalCode: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thông tin người tham gia
|
||||||
|
participantFirstName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
participantLastName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
participantBirthDate: {
|
||||||
|
type: Date,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
participantGender: {
|
||||||
|
type: String,
|
||||||
|
enum: ['male', 'female', 'other'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
numberOfParticipants: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
min: 1
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thông tin y tế và đặc biệt
|
||||||
|
medicalConditions: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
dietaryRestrictions: {
|
||||||
|
type: String,
|
||||||
|
enum: ['none', 'vegetarian', 'vegan', 'halal', 'kosher', 'gluten-free', 'other'],
|
||||||
|
default: 'none'
|
||||||
|
},
|
||||||
|
specialRequests: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thông tin liên hệ khẩn cấp
|
||||||
|
emergencyContact: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
emergencyPhone: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Điều khoản và thông báo
|
||||||
|
agreeTerms: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
agreeNewsletter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
|
||||||
|
// Trạng thái đăng ký
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'confirmed', 'cancelled', 'completed'],
|
||||||
|
default: 'pending'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ghi chú admin
|
||||||
|
adminNotes: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// Thông tin thanh toán
|
||||||
|
paymentStatus: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'partial', 'paid', 'refunded'],
|
||||||
|
default: 'pending'
|
||||||
|
},
|
||||||
|
totalAmount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
paidAmount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
toJSON: { virtuals: true },
|
||||||
|
toObject: { virtuals: true }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Virtual để tính tuổi của participant
|
||||||
|
bookingSubmissionSchema.virtual('participantAge').get(function() {
|
||||||
|
if (this.participantBirthDate) {
|
||||||
|
const today = new Date();
|
||||||
|
const birthDate = new Date(this.participantBirthDate);
|
||||||
|
let age = today.getFullYear() - birthDate.getFullYear();
|
||||||
|
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual để lấy thông tin activity
|
||||||
|
bookingSubmissionSchema.virtual('activity', {
|
||||||
|
ref: 'Activity',
|
||||||
|
localField: 'activityId',
|
||||||
|
foreignField: '_id',
|
||||||
|
justOne: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Index for better performance
|
||||||
|
bookingSubmissionSchema.index({ activityId: 1, sessionId: 1 });
|
||||||
|
bookingSubmissionSchema.index({ email: 1 });
|
||||||
|
bookingSubmissionSchema.index({ status: 1 });
|
||||||
|
bookingSubmissionSchema.index({ createdAt: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model("BookingSubmission", bookingSubmissionSchema);
|
||||||
245
models/campLocation.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Sub-schemas for better organization
|
||||||
|
const programOptionSchema = new mongoose.Schema({
|
||||||
|
_comment: String,
|
||||||
|
href: String,
|
||||||
|
imageSrc: String,
|
||||||
|
title: String,
|
||||||
|
description: String
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const locationSchema = new mongoose.Schema({
|
||||||
|
_comment: String,
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
country: String,
|
||||||
|
title: String,
|
||||||
|
imageSrc: String,
|
||||||
|
imageAlt: String,
|
||||||
|
readMoreLink: String,
|
||||||
|
imagePosition: {
|
||||||
|
type: String,
|
||||||
|
enum: ['left', 'right']
|
||||||
|
},
|
||||||
|
cardSize: {
|
||||||
|
type: String,
|
||||||
|
enum: ['default', 'large']
|
||||||
|
},
|
||||||
|
scrollspyClass: String,
|
||||||
|
programOptions: [programOptionSchema]
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const campMarkerSchema = new mongoose.Schema({
|
||||||
|
_comment: String,
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
lat: {
|
||||||
|
type: Number,
|
||||||
|
required: false // Changed to false since coordinates are optional
|
||||||
|
},
|
||||||
|
lng: {
|
||||||
|
type: Number,
|
||||||
|
required: false // Changed to false since coordinates are optional
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
image: String
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const faqItemSchema = new mongoose.Schema({
|
||||||
|
_comment: String,
|
||||||
|
question: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
answer: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const securityItemSchema = new mongoose.Schema({
|
||||||
|
_comment: String,
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
// Map schemas (similar to contact model)
|
||||||
|
const coordinatesSchema = new mongoose.Schema({
|
||||||
|
lat: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
lng: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const tileLayerSchema = new mongoose.Schema({
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
attribution: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
maxZoom: {
|
||||||
|
type: Number,
|
||||||
|
default: 18
|
||||||
|
},
|
||||||
|
minZoom: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
const mapSchema = new mongoose.Schema({
|
||||||
|
_comment: String,
|
||||||
|
coordinates: {
|
||||||
|
type: coordinatesSchema,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
type: Number,
|
||||||
|
default: 15
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
markerTitle: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
tileLayer: {
|
||||||
|
type: tileLayerSchema,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
// Main schema
|
||||||
|
const campLocationSchema = new mongoose.Schema({
|
||||||
|
metadata: {
|
||||||
|
_comment: String,
|
||||||
|
title: String,
|
||||||
|
description: String
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
_comment: String,
|
||||||
|
title: String,
|
||||||
|
backgroundImage: String,
|
||||||
|
overlayColor: String
|
||||||
|
},
|
||||||
|
camps: [campMarkerSchema],
|
||||||
|
locationsSection: {
|
||||||
|
_comment: String,
|
||||||
|
title: String,
|
||||||
|
readMoreButtonText: {
|
||||||
|
type: String,
|
||||||
|
default: 'read more'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
locations: [locationSchema],
|
||||||
|
intro: {
|
||||||
|
_comment: String,
|
||||||
|
content: String
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
coordinates: {
|
||||||
|
lat: { type: Number, default: 0 },
|
||||||
|
lng: { type: Number, default: 0 }
|
||||||
|
},
|
||||||
|
zoom: { type: Number, default: 5 },
|
||||||
|
location: { type: String, default: '' },
|
||||||
|
markerTitle: { type: String, default: '' },
|
||||||
|
tileLayer: {
|
||||||
|
url: { type: String, default: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||||
|
attribution: { type: String, default: '' },
|
||||||
|
maxZoom: { type: Number, default: 18 },
|
||||||
|
minZoom: { type: Number, default: 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
faqSection: {
|
||||||
|
_comment: String,
|
||||||
|
title: String,
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
default: 'More questions'
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
type: String,
|
||||||
|
default: 'comments'
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
default: '/info/faq'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
faq: [faqItemSchema],
|
||||||
|
welcomeQuote: {
|
||||||
|
_comment: String,
|
||||||
|
title: String,
|
||||||
|
quote: String,
|
||||||
|
author: String
|
||||||
|
},
|
||||||
|
securityConcept: {
|
||||||
|
_comment: String,
|
||||||
|
title: String,
|
||||||
|
introduction: String,
|
||||||
|
items: [securityItemSchema]
|
||||||
|
},
|
||||||
|
updatedAt: Date
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Indexes for better query performance
|
||||||
|
campLocationSchema.index({ 'camps.id': 1 });
|
||||||
|
campLocationSchema.index({ 'locations.id': 1 });
|
||||||
|
campLocationSchema.index({ 'locations.country': 1 });
|
||||||
|
|
||||||
|
// Virtual to get camp by ID
|
||||||
|
campLocationSchema.virtual('getCampById').get(function() {
|
||||||
|
return (id) => this.camps.find(camp => camp.id === id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual to get location by ID
|
||||||
|
campLocationSchema.virtual('getLocationById').get(function() {
|
||||||
|
return (id) => this.locations.find(location => location.id === id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method to get locations by country
|
||||||
|
campLocationSchema.methods.getLocationsByCountry = function(country) {
|
||||||
|
return this.locations.filter(location => location.country === country);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to get all countries
|
||||||
|
campLocationSchema.methods.getAllCountries = function() {
|
||||||
|
return [...new Set(this.locations.map(location => location.country))];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static method to get the latest camp location data
|
||||||
|
campLocationSchema.statics.getLatest = function() {
|
||||||
|
return this.findOne().sort({ updatedAt: -1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('CampLocation', campLocationSchema);
|
||||||
387
models/contact.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Schema cho hero section
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
overlayColor: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "rgba(0, 0, 0, 0)",
|
||||||
|
},
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
titleClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
enableScrollspy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
backgroundPosition: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho contact card
|
||||||
|
const contactCardSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
enum: [
|
||||||
|
"phone",
|
||||||
|
"email",
|
||||||
|
"location",
|
||||||
|
"hours",
|
||||||
|
"website",
|
||||||
|
"social",
|
||||||
|
"custom",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
iconType: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
iconSource: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
trim: true,
|
||||||
|
enum: ["fontawesome", "image"],
|
||||||
|
default: "fontawesome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho map coordinates
|
||||||
|
const coordinatesSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
lat: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
lng: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho tile layer
|
||||||
|
const tileLayerSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
attribution: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
maxZoom: {
|
||||||
|
type: Number,
|
||||||
|
default: 18,
|
||||||
|
},
|
||||||
|
minZoom: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho map
|
||||||
|
const mapSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
coordinates: {
|
||||||
|
type: coordinatesSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
type: Number,
|
||||||
|
default: 15,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
markerTitle: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
tileLayer: {
|
||||||
|
type: tileLayerSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho form field
|
||||||
|
const formFieldSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
enum: ["text", "email", "tel", "textarea", "programme"],
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
programmeName: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho submit button
|
||||||
|
const submitButtonSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho form
|
||||||
|
const formSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
sectionLabel: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
type: [formFieldSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
type: submitButtonSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main Contact Schema
|
||||||
|
const contactSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
type: heroSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contactCards: {
|
||||||
|
type: [contactCardSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
type: mapSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
type: formSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mapping iconType cũ sang Font Awesome icon mới
|
||||||
|
const iconTypeMapping = {
|
||||||
|
phone: "fas fa-phone",
|
||||||
|
email: "fas fa-envelope",
|
||||||
|
location: "fas fa-map-marker-alt",
|
||||||
|
clock: "fas fa-clock",
|
||||||
|
hours: "fas fa-clock",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo migration script để import dữ liệu từ contact-data.json
|
||||||
|
contactSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
try {
|
||||||
|
// Kiểm tra xem đã có contact mặc định chưa
|
||||||
|
const existingContact = await this.findOne({ name: "default" });
|
||||||
|
|
||||||
|
// Xử lý và chuẩn hóa dữ liệu từ JSON
|
||||||
|
const processedData = {
|
||||||
|
hero: {
|
||||||
|
title: jsonData.hero?.title || "Contact Us",
|
||||||
|
backgroundImage: jsonData.hero?.backgroundImage || "",
|
||||||
|
overlayColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)",
|
||||||
|
sectionClass: jsonData.hero?.sectionClass || "",
|
||||||
|
titleClass: jsonData.hero?.titleClass || "",
|
||||||
|
enableScrollspy: jsonData.hero?.enableScrollspy || false,
|
||||||
|
backgroundPosition: jsonData.hero?.backgroundPosition || "center",
|
||||||
|
},
|
||||||
|
contactCards: (jsonData.contactCards || []).map((card) => {
|
||||||
|
let iconType = card.iconType || "";
|
||||||
|
let iconSource = card.iconSource;
|
||||||
|
|
||||||
|
// Nếu không có iconSource, tự động detect từ iconType
|
||||||
|
if (!iconSource) {
|
||||||
|
// Nếu iconType là image path (bắt đầu bằng /uploads/ hoặc http)
|
||||||
|
if (
|
||||||
|
iconType.startsWith("/uploads/") ||
|
||||||
|
iconType.startsWith("http://") ||
|
||||||
|
iconType.startsWith("https://")
|
||||||
|
) {
|
||||||
|
iconSource = "image";
|
||||||
|
} else {
|
||||||
|
// Nếu iconType là string cũ (phone, email, location, clock)
|
||||||
|
iconSource = "fontawesome";
|
||||||
|
// Map iconType cũ sang Font Awesome icon mới
|
||||||
|
if (iconTypeMapping[iconType]) {
|
||||||
|
iconType = iconTypeMapping[iconType];
|
||||||
|
} else if (
|
||||||
|
iconType &&
|
||||||
|
!iconType.startsWith("fas ") &&
|
||||||
|
!iconType.startsWith("fab ")
|
||||||
|
) {
|
||||||
|
// Nếu iconType không phải là Font Awesome class hợp lệ, thử map
|
||||||
|
iconType = iconTypeMapping[iconType] || iconType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nếu đã có iconSource nhưng iconType là string cũ, map sang Font Awesome
|
||||||
|
if (
|
||||||
|
iconSource === "fontawesome" &&
|
||||||
|
iconType &&
|
||||||
|
!iconType.startsWith("fas ") &&
|
||||||
|
!iconType.startsWith("fab ") &&
|
||||||
|
iconTypeMapping[iconType]
|
||||||
|
) {
|
||||||
|
iconType = iconTypeMapping[iconType];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: card.type || "custom",
|
||||||
|
title: card.title || "",
|
||||||
|
content: Array.isArray(card.content) ? card.content : [],
|
||||||
|
iconType: iconType,
|
||||||
|
iconSource: iconSource || "fontawesome",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
map: {
|
||||||
|
coordinates: {
|
||||||
|
lat: jsonData.map?.coordinates?.lat || 0,
|
||||||
|
lng: jsonData.map?.coordinates?.lng || 0,
|
||||||
|
},
|
||||||
|
zoom: jsonData.map?.zoom || 15,
|
||||||
|
location: jsonData.map?.location || "",
|
||||||
|
markerTitle: jsonData.map?.markerTitle || "",
|
||||||
|
tileLayer: {
|
||||||
|
url:
|
||||||
|
jsonData.map?.tileLayer?.url ||
|
||||||
|
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
attribution: jsonData.map?.tileLayer?.attribution || "",
|
||||||
|
maxZoom: jsonData.map?.tileLayer?.maxZoom || 18,
|
||||||
|
minZoom: jsonData.map?.tileLayer?.minZoom || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
sectionLabel: jsonData.form?.sectionLabel || "",
|
||||||
|
heading: jsonData.form?.heading || "",
|
||||||
|
fields: (jsonData.form?.fields || []).map((field) => ({
|
||||||
|
name: field.name || "",
|
||||||
|
type: field.type || "text",
|
||||||
|
placeholder: field.placeholder || "",
|
||||||
|
required: field.required || false,
|
||||||
|
programmeName: field.programmeName || "",
|
||||||
|
})),
|
||||||
|
submitButton: {
|
||||||
|
text: jsonData.form?.submitButton?.text || "Send Message",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingContact) {
|
||||||
|
// Cập nhật contact hiện có với dữ liệu đã xử lý
|
||||||
|
existingContact.hero = processedData.hero;
|
||||||
|
existingContact.contactCards = processedData.contactCards;
|
||||||
|
existingContact.map = processedData.map;
|
||||||
|
existingContact.form = processedData.form;
|
||||||
|
await existingContact.save();
|
||||||
|
console.log("Contact data updated successfully");
|
||||||
|
return existingContact;
|
||||||
|
} else {
|
||||||
|
// Tạo contact mới với dữ liệu đã xử lý
|
||||||
|
const newContact = await this.create({
|
||||||
|
name: "default",
|
||||||
|
...processedData,
|
||||||
|
});
|
||||||
|
console.log("Contact data imported successfully");
|
||||||
|
return newContact;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error migrating contact data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Contact", contactSchema);
|
||||||
32
models/department.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Helper function to generate slug
|
||||||
|
function generateSlug(name) {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||||
|
.replace(/\s+/g, '-') // Replace spaces with -
|
||||||
|
.replace(/-+/g, '-'); // Replace multiple - with single -
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
unique: true,
|
||||||
|
lowercase: true
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
lowercase: true,
|
||||||
|
index: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: false // Thêm timestamps để theo dõi thời gian tạo/cập nhật
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Department', departmentSchema);
|
||||||
222
models/faq.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const faqItemSchema = new mongoose.Schema({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}, { _id: true });
|
||||||
|
|
||||||
|
const faqSectionSchema = new mongoose.Schema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
faqs: [faqItemSchema]
|
||||||
|
}, { _id: true });
|
||||||
|
|
||||||
|
const sidebarNavSchema = new mongoose.Schema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroSchema = new mongoose.Schema({
|
||||||
|
title: String,
|
||||||
|
backgroundImage: String,
|
||||||
|
overlayColor: String,
|
||||||
|
sectionClass: String,
|
||||||
|
titleClass: String,
|
||||||
|
enableScrollspy: Boolean,
|
||||||
|
backgroundPosition: String
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactBoxSchema = new mongoose.Schema({
|
||||||
|
title: String,
|
||||||
|
phone: {
|
||||||
|
icon: String,
|
||||||
|
text: String
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
icon: String,
|
||||||
|
text: String
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoSchema = new mongoose.Schema({
|
||||||
|
url: String,
|
||||||
|
title: String
|
||||||
|
});
|
||||||
|
|
||||||
|
const faqSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
|
||||||
|
},
|
||||||
|
hero: heroSchema,
|
||||||
|
sidebarNav: [sidebarNavSchema],
|
||||||
|
contactBox: contactBoxSchema,
|
||||||
|
faqSections: [faqSectionSchema],
|
||||||
|
video: videoSchema
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual property để tính tổng số FAQ items
|
||||||
|
faqSchema.virtual('totalFaqs').get(function() {
|
||||||
|
return this.faqSections.reduce((total, section) => {
|
||||||
|
return total + (section.faqs ? section.faqs.length : 0);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static method: Lấy FAQ mặc định
|
||||||
|
faqSchema.statics.getDefault = async function() {
|
||||||
|
let faq = await this.findOne({ name: 'default' });
|
||||||
|
|
||||||
|
// Nếu không có, tạo mới
|
||||||
|
if (!faq) {
|
||||||
|
faq = new this({
|
||||||
|
name: 'default',
|
||||||
|
hero: {
|
||||||
|
title: 'Frequently Asked Questions',
|
||||||
|
backgroundImage: 'yootheme/cache/18/faqs_header_new.jpg',
|
||||||
|
overlayColor: 'rgba(0, 0, 0, 0)',
|
||||||
|
sectionClass: 'uk-section-secondary uk-section-overlap uk-preserve-color uk-light',
|
||||||
|
titleClass: 'uk-heading-large uk-text-center !text-[5vw]',
|
||||||
|
enableScrollspy: true,
|
||||||
|
backgroundPosition: 'top-center'
|
||||||
|
},
|
||||||
|
sidebarNav: [
|
||||||
|
{ id: 'general-information', label: 'General Information' }
|
||||||
|
],
|
||||||
|
contactBox: {
|
||||||
|
title: 'Let\'s plan your perfect nature escape',
|
||||||
|
phone: { icon: 'phone', text: '+(123)-456-789' },
|
||||||
|
email: { icon: 'email', text: 'hello@ggcamp.org' }
|
||||||
|
},
|
||||||
|
faqSections: [
|
||||||
|
{
|
||||||
|
id: 'general-information',
|
||||||
|
title: 'General Information',
|
||||||
|
faqs: [
|
||||||
|
{
|
||||||
|
title: 'Sample FAQ Question',
|
||||||
|
description: 'This is a sample FAQ answer. Please update with your actual content.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await faq.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return faq;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static method: Import từ JSON
|
||||||
|
faqSchema.statics.importFromJson = async function(data) {
|
||||||
|
let faq = await this.findOne({ name: 'default' });
|
||||||
|
|
||||||
|
// Đảm bảo có name
|
||||||
|
const faqData = {
|
||||||
|
name: 'default',
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!faq) {
|
||||||
|
faq = new this(faqData);
|
||||||
|
} else {
|
||||||
|
// Update các trường
|
||||||
|
Object.keys(faqData).forEach(key => {
|
||||||
|
faq[key] = faqData[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await faq.save();
|
||||||
|
return faq;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method: Thêm FAQ vào section
|
||||||
|
faqSchema.methods.addFaqToSection = async function(sectionId, faqItem) {
|
||||||
|
const section = this.faqSections.find(s => s.id === sectionId);
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
throw new Error(`Section with id ${sectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.faqs.push(faqItem);
|
||||||
|
await this.save();
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method: Update FAQ item
|
||||||
|
faqSchema.methods.updateFaqItem = async function(sectionId, faqId, updates) {
|
||||||
|
const section = this.faqSections.find(s => s.id === sectionId);
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
throw new Error(`Section with id ${sectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const faqItem = section.faqs.id(faqId);
|
||||||
|
|
||||||
|
if (!faqItem) {
|
||||||
|
throw new Error(`FAQ item with id ${faqId} not found in section ${sectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.title !== undefined) faqItem.title = updates.title;
|
||||||
|
if (updates.description !== undefined) faqItem.description = updates.description;
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method: Delete FAQ item
|
||||||
|
faqSchema.methods.deleteFaqItem = async function(sectionId, faqId) {
|
||||||
|
const section = this.faqSections.find(s => s.id === sectionId);
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
throw new Error(`Section with id ${sectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const faqItem = section.faqs.id(faqId);
|
||||||
|
|
||||||
|
if (!faqItem) {
|
||||||
|
throw new Error(`FAQ item with id ${faqId} not found in section ${sectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.faqs.pull(faqId);
|
||||||
|
await this.save();
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method: Update FAQ section
|
||||||
|
faqSchema.methods.updateFaqSection = async function(sectionId, updates) {
|
||||||
|
const section = this.faqSections.find(s => s.id === sectionId);
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
throw new Error(`Section with id ${sectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.title !== undefined) section.title = updates.title;
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FAQ = mongoose.model('FAQ', faqSchema);
|
||||||
|
|
||||||
|
module.exports = FAQ;
|
||||||
234
models/footer.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Schema cho social links
|
||||||
|
const socialLinkSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
platform: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho footer links
|
||||||
|
const footerLinkSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho footer columns
|
||||||
|
const footerColumnSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
type: [footerLinkSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho logo
|
||||||
|
const logoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho address
|
||||||
|
const addressSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
// Thêm address2 (địa chỉ dòng 2) không bắt buộc
|
||||||
|
address2: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
// Optional secondary link (e.g., a second map or external resource)
|
||||||
|
link2: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
mapUrl: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho contact info
|
||||||
|
const contactInfoSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
phone: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
hours: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho copyright
|
||||||
|
const copyrightSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho about section (column đầu tiên)
|
||||||
|
const aboutSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
mapLink: {
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main Footer Schema
|
||||||
|
const footerSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
type: aboutSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: addressSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
type: contactInfoSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: [footerColumnSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
links: {
|
||||||
|
type: [socialLinkSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
copyright: {
|
||||||
|
type: copyrightSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tạo migration script để import dữ liệu từ footer.json
|
||||||
|
footerSchema.statics.migrateFromJson = async function (jsonData) {
|
||||||
|
try {
|
||||||
|
// Kiểm tra xem đã có footer mặc định chưa
|
||||||
|
const existingFooter = await this.findOne({ name: "default" });
|
||||||
|
|
||||||
|
if (existingFooter) {
|
||||||
|
// Cập nhật footer hiện có
|
||||||
|
Object.assign(existingFooter, jsonData);
|
||||||
|
await existingFooter.save();
|
||||||
|
console.log("Footer data updated successfully");
|
||||||
|
return existingFooter;
|
||||||
|
} else {
|
||||||
|
// Tạo footer mới với dữ liệu từ JSON
|
||||||
|
const newFooter = await this.create({
|
||||||
|
name: "default",
|
||||||
|
...jsonData,
|
||||||
|
});
|
||||||
|
console.log("Footer data imported successfully");
|
||||||
|
return newFooter;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error migrating footer data:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Footer", footerSchema);
|
||||||
51
models/form.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const formSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
admission: {
|
||||||
|
background_image: String,
|
||||||
|
title: String,
|
||||||
|
year: String,
|
||||||
|
description: String,
|
||||||
|
form: {
|
||||||
|
fields: [{
|
||||||
|
type: { type: String },
|
||||||
|
placeholder: String
|
||||||
|
}],
|
||||||
|
button: {
|
||||||
|
text: String,
|
||||||
|
url: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apply: {
|
||||||
|
title: String,
|
||||||
|
steps: [{
|
||||||
|
title: String,
|
||||||
|
description: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
application_form: {
|
||||||
|
title: String,
|
||||||
|
question: String,
|
||||||
|
button: {
|
||||||
|
text: String,
|
||||||
|
icon: String,
|
||||||
|
url: String
|
||||||
|
},
|
||||||
|
links: [{
|
||||||
|
text: String,
|
||||||
|
url: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
updatedAt: Date
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Form', formSchema);
|
||||||
129
models/header.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Schema cho các link trong topbar
|
||||||
|
const topbarLinkSchema = new mongoose.Schema({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
// Schema cho contact info trong topbar
|
||||||
|
const contactInfoSchema = new mongoose.Schema({
|
||||||
|
phone: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
// Schema cho topbar
|
||||||
|
const topbarSchema = new mongoose.Schema({
|
||||||
|
contactInfo: {
|
||||||
|
type: contactInfoSchema,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
type: [topbarLinkSchema],
|
||||||
|
default: []
|
||||||
|
}
|
||||||
|
}, { _id: false });
|
||||||
|
|
||||||
|
// Main Header Schema
|
||||||
|
const headerSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
topbar: {
|
||||||
|
type: topbarSchema,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method để lấy menu tree từ collection menuHeader
|
||||||
|
headerSchema.statics.getMenuTree = async function() {
|
||||||
|
try {
|
||||||
|
const Menu = require('./menuHeader');
|
||||||
|
return await Menu.getMenuTree();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting menu tree:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để lấy menu tree với programmes từ collection menuHeader
|
||||||
|
headerSchema.statics.getMenuTreeWithProgrammes = async function() {
|
||||||
|
try {
|
||||||
|
const Menu = require('./menuHeader');
|
||||||
|
return await Menu.getMenuTreeWithProgrammes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting menu tree with programmes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để lấy programmes theo menu ID
|
||||||
|
headerSchema.statics.getProgrammesByMenuId = async function(menuId) {
|
||||||
|
try {
|
||||||
|
const Menu = require('./menuHeader');
|
||||||
|
return await Menu.getProgrammesByMenuId(menuId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting programmes by menu ID:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo migration script để import dữ liệu từ menu.json
|
||||||
|
headerSchema.statics.migrateFromJson = async function(jsonData) {
|
||||||
|
try {
|
||||||
|
// Kiểm tra xem đã có header mặc định chưa
|
||||||
|
const existingHeader = await this.findOne({ name: 'default' });
|
||||||
|
|
||||||
|
// Chỉ giữ lại topbar và logo, bỏ search và mainMenu
|
||||||
|
const headerData = {
|
||||||
|
topbar: jsonData.topbar,
|
||||||
|
logo: jsonData.logo
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingHeader) {
|
||||||
|
// Cập nhật header hiện có
|
||||||
|
Object.assign(existingHeader, headerData);
|
||||||
|
await existingHeader.save();
|
||||||
|
console.log('Header data updated successfully');
|
||||||
|
return existingHeader;
|
||||||
|
} else {
|
||||||
|
// Tạo header mới với dữ liệu từ JSON
|
||||||
|
const newHeader = await this.create({
|
||||||
|
name: 'default',
|
||||||
|
...headerData
|
||||||
|
});
|
||||||
|
console.log('Header data imported successfully');
|
||||||
|
return newHeader;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error migrating header data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Header', headerSchema);
|
||||||
96
models/hero.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const heroSchema = new mongoose.Schema({
|
||||||
|
hero: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
overlayColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'rgba(0, 0, 0, 0.35)'
|
||||||
|
},
|
||||||
|
enableScrollspy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
backgroundPosition: {
|
||||||
|
type: String,
|
||||||
|
default: 'center center'
|
||||||
|
},
|
||||||
|
containerStyles: {
|
||||||
|
width: { type: String, default: '98%' },
|
||||||
|
height: { type: String, default: '130vh' },
|
||||||
|
margin: { type: String, default: '0 auto' },
|
||||||
|
borderRadius: { type: String, default: '2vw' },
|
||||||
|
overflow: { type: String, default: 'hidden' },
|
||||||
|
position: { type: String, default: 'relative' },
|
||||||
|
top: { type: String, default: '-10vh' }
|
||||||
|
},
|
||||||
|
titleClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'uk-heading-large uk-text-center uk-text-white'
|
||||||
|
},
|
||||||
|
titleStyles: {
|
||||||
|
fontSize: { type: String, default: 'clamp(2rem, 5vw, 4.5rem)' },
|
||||||
|
fontWeight: { type: String, default: 'bold' },
|
||||||
|
lineHeight: { type: String, default: '1.2' },
|
||||||
|
marginBottom: { type: String, default: '1.5rem' },
|
||||||
|
color: { type: String, default: 'white' },
|
||||||
|
textShadow: { type: String, default: '0 2px 10px rgba(0, 0, 0, 0.3)' }
|
||||||
|
},
|
||||||
|
descriptionClass: {
|
||||||
|
type: String,
|
||||||
|
default: 'uk-text-white'
|
||||||
|
},
|
||||||
|
descriptionStyles: {
|
||||||
|
fontSize: { type: String, default: 'clamp(1rem, 1.5vw, 1.25rem)' },
|
||||||
|
maxWidth: { type: String, default: '800px' },
|
||||||
|
margin: { type: String, default: '0 auto 2rem' },
|
||||||
|
lineHeight: { type: String, default: '1.6' },
|
||||||
|
color: { type: String, default: 'white' },
|
||||||
|
textShadow: { type: String, default: '0 1px 5px rgba(0, 0, 0, 0.3)' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Book Your Adventure'
|
||||||
|
},
|
||||||
|
href: {
|
||||||
|
type: String,
|
||||||
|
default: '/booking'
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'magic'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contactBox: {
|
||||||
|
enabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
position: { type: String, default: 'absolute' },
|
||||||
|
bottom: { type: String, default: '3rem' },
|
||||||
|
left: { type: String, default: '50%' },
|
||||||
|
transform: { type: String, default: 'translateX(-50%)' },
|
||||||
|
width: { type: String, default: '100%' },
|
||||||
|
zIndex: { type: Number, default: 3 },
|
||||||
|
padding: { type: String, default: '0 1rem' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Hero', heroSchema);
|
||||||
177
models/home.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const homeSchema = new mongoose.Schema({
|
||||||
|
// New structure - Camp data
|
||||||
|
hero: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
backgroundImage: { type: String, default: '' },
|
||||||
|
button: {
|
||||||
|
label: { type: String, default: 'Book Your Adventure' },
|
||||||
|
href: { type: String, default: '/booking' }
|
||||||
|
},
|
||||||
|
contactBox: {
|
||||||
|
welcomeText: { type: String, default: '' },
|
||||||
|
phone: {
|
||||||
|
label: { type: String, default: 'Call us' },
|
||||||
|
number: { type: String, default: '' },
|
||||||
|
href: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
label: { type: String, default: 'Email' },
|
||||||
|
address: { type: String, default: '' },
|
||||||
|
href: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
workingHours: {
|
||||||
|
label: { type: String, default: 'Working Hours' },
|
||||||
|
hours: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
images: {
|
||||||
|
mainImage1: { type: String, default: '' },
|
||||||
|
mainImage2: { type: String, default: '' },
|
||||||
|
avatars: [{ type: String }]
|
||||||
|
},
|
||||||
|
features: [{ type: String }],
|
||||||
|
quote: { type: String, default: '' },
|
||||||
|
button: {
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
href: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
customerCount: { type: Number, default: 0 },
|
||||||
|
customerLabel: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
missionVision: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
backgroundImage: { type: String, default: '' },
|
||||||
|
cards: [{
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
whyChooseUs: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
button: {
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
href: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
features: [{
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' }
|
||||||
|
}],
|
||||||
|
tags: [{ type: String }],
|
||||||
|
cta: {
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
linkText: { type: String, default: '' },
|
||||||
|
linkHref: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activities: {
|
||||||
|
cards: [{
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
image: { type: String, default: '' }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
contact: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
info: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
questions: [{
|
||||||
|
question: { type: String, default: '' },
|
||||||
|
answer: { type: String, default: '' }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
partners: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
backgroundImage: { type: String, default: '' },
|
||||||
|
logos: [{ type: String }],
|
||||||
|
cta: {
|
||||||
|
badge: { type: String, default: '' },
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
linkText: { type: String, default: '' },
|
||||||
|
linkHref: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
programs: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
button: {
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
href: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
pricePrefix: { type: String, default: 'from' },
|
||||||
|
priceSuffix: { type: String, default: 'USD' },
|
||||||
|
buttonLabel: { type: String, default: 'Camp Detail' },
|
||||||
|
buttonHref: { type: String, default: '/camp-profiles' }
|
||||||
|
},
|
||||||
|
items: [{
|
||||||
|
id: { type: String, default: '' },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
price: { type: String, default: '' },
|
||||||
|
seasons: [{ type: String }],
|
||||||
|
age: { type: String, default: '' },
|
||||||
|
location: { type: String, default: '' },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
slug: { type: String, default: '' }
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
newsletter: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
decorativeImage: { type: String, default: '' },
|
||||||
|
button: {
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
href: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
latestPosts: {
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
subtitle: { type: String, default: '' },
|
||||||
|
searchPlaceholder: { type: String, default: '' },
|
||||||
|
sidebarTitle: { type: String, default: '' },
|
||||||
|
blogPosts: [{
|
||||||
|
id: { type: Number },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
date: { type: String, default: '' }
|
||||||
|
}],
|
||||||
|
sidebarPosts: [{
|
||||||
|
id: { type: Number },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' }
|
||||||
|
}],
|
||||||
|
featuredCard: {
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Home', homeSchema);
|
||||||
302
models/insurance.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Schema cho content items
|
||||||
|
const contentItemSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ["paragraph", "section", "list", "note", "embed", "header"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
// Embed/video fields
|
||||||
|
embed: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
videoId: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho overlay style
|
||||||
|
const overlayStyleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
backgroundColor: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "rgba(0, 0, 0, 0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho hero section
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
default: "Insurance & Travel Cancellation Guarantee",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "Comprehensive coverage for your peace of mind",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "/uploads/banner/b13.jpg",
|
||||||
|
},
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
},
|
||||||
|
backgroundClasses: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
},
|
||||||
|
overlayStyle: {
|
||||||
|
type: overlayStyleSchema,
|
||||||
|
default: () => ({ backgroundColor: "rgba(0, 0, 0, 0)" }),
|
||||||
|
},
|
||||||
|
titleClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "text-white text-[5vw] uk-text-center",
|
||||||
|
},
|
||||||
|
subtitleClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
|
||||||
|
},
|
||||||
|
enableScrollspy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho page section
|
||||||
|
const pageSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
default: "Insurance & Travel Information",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-section-default uk-section-overlap uk-section",
|
||||||
|
},
|
||||||
|
titleClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
},
|
||||||
|
dividerClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-divider-small uk-text-left@m uk-text-center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho content section
|
||||||
|
const contentSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
},
|
||||||
|
textClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-panel uk-margin text-[1vw]",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: [contentItemSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main Insurance Schema - CẤU TRÚC MỚI
|
||||||
|
const insuranceSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
// 3 PHẦN CHÍNH
|
||||||
|
hero: {
|
||||||
|
type: heroSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
type: pageSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: contentSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: String,
|
||||||
|
default: "en",
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: String,
|
||||||
|
default: "2.0.0",
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
migratedFromOldStructure: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Static method: Lấy insurance default
|
||||||
|
insuranceSchema.statics.getDefault = async function(language = "en") {
|
||||||
|
try {
|
||||||
|
let insurance = await this.findOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (!insurance) {
|
||||||
|
// Tạo default data nếu chưa có
|
||||||
|
insurance = await this.create({
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
hero: {
|
||||||
|
title: "Insurance & Travel Cancellation Guarantee",
|
||||||
|
subtitle: "Comprehensive coverage for your peace of mind",
|
||||||
|
backgroundImage: "/uploads/banner/b13.jpg",
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
title: "Insurance & Travel Information",
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
content: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return insurance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in getDefault:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để get insurance data
|
||||||
|
insuranceSchema.methods.getInsuranceData = function() {
|
||||||
|
return this.toObject();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Migration method - chỉ hỗ trợ cấu trúc mới
|
||||||
|
insuranceSchema.statics.migrateFromJson = async function(jsonData, language = "en") {
|
||||||
|
try {
|
||||||
|
console.log('Migrating insurance from JSON...');
|
||||||
|
|
||||||
|
// Xóa document cũ nếu có
|
||||||
|
await this.deleteOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
// Sử dụng dữ liệu từ JSON trực tiếp
|
||||||
|
const processedData = {
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
hero: jsonData.hero,
|
||||||
|
page: jsonData.page,
|
||||||
|
content: jsonData.content
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo document mới
|
||||||
|
const newInsurance = await this.create(processedData);
|
||||||
|
const contentItems = jsonData.content?.content || [];
|
||||||
|
console.log(`Insurance data migrated successfully for language: ${language}`);
|
||||||
|
console.log(`Total content items: ${contentItems.length}`);
|
||||||
|
|
||||||
|
return newInsurance;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error migrating insurance data to new structure:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Insurance = mongoose.model("Insurance", insuranceSchema);
|
||||||
|
|
||||||
|
module.exports = Insurance;
|
||||||
67
models/level.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const levelSchema = new mongoose.Schema({
|
||||||
|
brochure: { type: String },
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
image: String,
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
title: String,
|
||||||
|
paragraphs: [String],
|
||||||
|
contact_info: {
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
items: [{
|
||||||
|
text: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
social_info: {
|
||||||
|
title: String,
|
||||||
|
social_links: [{
|
||||||
|
image: String,
|
||||||
|
url: String,
|
||||||
|
alt: String
|
||||||
|
}],
|
||||||
|
apply_button: {
|
||||||
|
text: String,
|
||||||
|
url: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requirements: {
|
||||||
|
title: String,
|
||||||
|
items: [String]
|
||||||
|
},
|
||||||
|
action_buttons: {
|
||||||
|
title: String,
|
||||||
|
buttons: [{
|
||||||
|
text: String,
|
||||||
|
link: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
why_study: {
|
||||||
|
title: String,
|
||||||
|
items: [{
|
||||||
|
number: String,
|
||||||
|
title: String,
|
||||||
|
text: String
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
// Thêm tham chiếu đến Form
|
||||||
|
form: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Form'
|
||||||
|
},
|
||||||
|
updatedAt: Date
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Level', levelSchema);
|
||||||
185
models/menuHeader.js
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const MenuSchema = new mongoose.Schema({
|
||||||
|
menuid: { type: String, required: true, unique: true }, // ID tùy chỉnh
|
||||||
|
parent: { type: String, default: null }, // ID menu cha
|
||||||
|
title: { type: String, required: true }, // Tên hiển thị
|
||||||
|
url: { type: String, default: '' }, // Đường dẫn
|
||||||
|
order: { type: Number, default: 0 }, // Thứ tự hiển thị
|
||||||
|
type: { type: String, enum: ['static', 'page', 'level'], default: 'static' }, // Loại menu
|
||||||
|
fetch: { type: Boolean, default: false }, // Có fetch programme không
|
||||||
|
isActive: { type: Boolean, default: true } // Trạng thái hoạt động của menu
|
||||||
|
}, { timestamps: false });
|
||||||
|
|
||||||
|
// Index để tối ưu query
|
||||||
|
MenuSchema.index({ parent: 1, order: 1 });
|
||||||
|
MenuSchema.index({ type: 1 });
|
||||||
|
|
||||||
|
// Method để lấy menu tree
|
||||||
|
MenuSchema.statics.getMenuTree = async function () {
|
||||||
|
try {
|
||||||
|
const allMenus = await this.find().sort({ order: 1 }).lean();
|
||||||
|
|
||||||
|
const menuMap = new Map();
|
||||||
|
const rootMenus = [];
|
||||||
|
|
||||||
|
// Đưa tất cả menus vào map và xử lý URL dựa trên isActive
|
||||||
|
allMenus.forEach(menu => {
|
||||||
|
const activeUrl = menu.isActive === false ? '/maintenance/' : menu.url;
|
||||||
|
menuMap.set(menu.menuid, {
|
||||||
|
...menu,
|
||||||
|
url: activeUrl, // Sử dụng URL đã được xử lý
|
||||||
|
children: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Xây dựng tree structure
|
||||||
|
allMenus.forEach(menu => {
|
||||||
|
const menuObj = menuMap.get(menu.menuid);
|
||||||
|
|
||||||
|
if (!menu.parent) {
|
||||||
|
rootMenus.push(menuObj);
|
||||||
|
} else {
|
||||||
|
const parent = menuMap.get(menu.parent);
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(menuObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootMenus;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error building menu tree:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để lấy menu tree với programmes
|
||||||
|
MenuSchema.statics.getMenuTreeWithProgrammes = async function () {
|
||||||
|
try {
|
||||||
|
const menuTree = await this.getMenuTree();
|
||||||
|
|
||||||
|
// Thêm programmes cho các menu level
|
||||||
|
for (const menu of menuTree) {
|
||||||
|
await this.addProgrammesToMenu(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuTree;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error building menu tree with programmes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để thêm programmes vào menu
|
||||||
|
MenuSchema.statics.addProgrammesToMenu = async function (menuItem) {
|
||||||
|
try {
|
||||||
|
if (menuItem.type === 'level' && menuItem.fetch) {
|
||||||
|
const programmes = await this.getProgrammesByMenuId(menuItem.menuid);
|
||||||
|
menuItem.programmes = programmes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuItem.children && menuItem.children.length > 0) {
|
||||||
|
for (const child of menuItem.children) {
|
||||||
|
await this.addProgrammesToMenu(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding programmes to menu:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để lấy programmes theo menu ID
|
||||||
|
MenuSchema.statics.getProgrammesByMenuId = async function (menuId) {
|
||||||
|
try {
|
||||||
|
const Programme = require('./programme');
|
||||||
|
const Level = require('./level');
|
||||||
|
|
||||||
|
// Sử dụng trực tiếp menuId làm levelType vì menuid đã được đặt đúng khi tạo
|
||||||
|
const levelType = menuId;
|
||||||
|
|
||||||
|
const level = await Level.findOne({ type: levelType });
|
||||||
|
if (!level) return [];
|
||||||
|
|
||||||
|
const programmes = await Programme.find({ level: level._id })
|
||||||
|
.select('name code level_type')
|
||||||
|
.sort({ name: 1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
return programmes;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting programmes by menu ID:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để tạo menu mới
|
||||||
|
MenuSchema.statics.createMenu = async function (menuData) {
|
||||||
|
try {
|
||||||
|
const menu = new this(menuData);
|
||||||
|
await menu.save();
|
||||||
|
return menu;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating menu:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để cập nhật menu
|
||||||
|
MenuSchema.statics.updateMenu = async function (menuId, updateData) {
|
||||||
|
try {
|
||||||
|
const menu = await this.findOneAndUpdate({ menuid: menuId }, updateData, { new: true });
|
||||||
|
return menu;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating menu:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để xóa menu
|
||||||
|
MenuSchema.statics.deleteMenu = async function (menuId) {
|
||||||
|
try {
|
||||||
|
// Xóa tất cả children trước
|
||||||
|
await this.deleteMany({ parent: menuId });
|
||||||
|
// Sau đó xóa menu chính
|
||||||
|
const result = await this.findOneAndDelete({ menuid: menuId });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting menu:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để sắp xếp lại order
|
||||||
|
MenuSchema.statics.reorderMenus = async function (parentId, menuIds) {
|
||||||
|
try {
|
||||||
|
const updates = menuIds.map((menuId, index) => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { menuid: menuId },
|
||||||
|
update: { order: index }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.bulkWrite(updates);
|
||||||
|
console.log('Menus reordered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reordering menus:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để lấy URL dựa trên trạng thái isActive
|
||||||
|
MenuSchema.methods.getActiveUrl = function () {
|
||||||
|
if (this.isActive === false) {
|
||||||
|
return '/maintenance/';
|
||||||
|
}
|
||||||
|
return this.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để kiểm tra trạng thái hoạt động
|
||||||
|
MenuSchema.methods.isMenuActive = function () {
|
||||||
|
return this.isActive !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = mongoose.model('MenuHeader', MenuSchema);
|
||||||
28
models/migration.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const migrationSchema = new mongoose.Schema({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
index: true
|
||||||
|
},
|
||||||
|
batch: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
ranAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Index để tìm kiếm nhanh
|
||||||
|
migrationSchema.index({ name: 1 });
|
||||||
|
migrationSchema.index({ batch: -1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Migration', migrationSchema);
|
||||||
|
|
||||||
76
models/safety.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Schema cho hero section
|
||||||
|
const safetySchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
//hero section
|
||||||
|
hero: {
|
||||||
|
banner: String,
|
||||||
|
title: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
//approach section
|
||||||
|
approach: {
|
||||||
|
badge: String,
|
||||||
|
title:String,
|
||||||
|
description:String,
|
||||||
|
imgs:{
|
||||||
|
img1:String,
|
||||||
|
img2:String
|
||||||
|
},
|
||||||
|
stats:{
|
||||||
|
count:String,
|
||||||
|
label:String,
|
||||||
|
avatars:[String]
|
||||||
|
},
|
||||||
|
features:[
|
||||||
|
{text:String}
|
||||||
|
],
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
//philosophy section
|
||||||
|
philosophy: {
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
author: {
|
||||||
|
avt: String,
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
rating: String,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
//security section
|
||||||
|
security: {
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
author: {
|
||||||
|
avt: String,
|
||||||
|
name: String,
|
||||||
|
role: String,
|
||||||
|
rating: String,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Safety", safetySchema);
|
||||||
519
models/terms.js
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
// models/terms.js
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
// Schema cho content items
|
||||||
|
const contentItemSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
enum: ["paragraph", "section", "header", "list", "cancellation_table", "cancellation_section", "note", "embed", "image"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
// Header level (h2, h3, h4, h5, h6)
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
min: 1,
|
||||||
|
max: 6,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
subsections: {
|
||||||
|
type: [mongoose.Schema.Types.Mixed], // Recursive reference
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
// List style (for list type)
|
||||||
|
style: {
|
||||||
|
type: String,
|
||||||
|
enum: ["ordered", "unordered"],
|
||||||
|
default: "unordered",
|
||||||
|
},
|
||||||
|
// Embed/video fields (optional)
|
||||||
|
embed: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
videoId: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho overlay style
|
||||||
|
const overlayStyleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
backgroundColor: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "rgba(0, 0, 0, 0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho hero section - CẤU TRÚC MỚI
|
||||||
|
const heroSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
default: "Frequently Asked Questions",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "/uploads/terms/faqimage.jpg",
|
||||||
|
},
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
},
|
||||||
|
backgroundClasses: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
},
|
||||||
|
overlayStyle: {
|
||||||
|
type: overlayStyleSchema,
|
||||||
|
default: () => ({ backgroundColor: "rgba(0, 0, 0, 0)" }),
|
||||||
|
},
|
||||||
|
titleClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "text-white text-[5vw] uk-text-center",
|
||||||
|
},
|
||||||
|
enableScrollspy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho page section - CẤU TRÚC MỚI
|
||||||
|
const pageSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true,
|
||||||
|
default: "Terms & Conditions Go and Grow Camp e.K.",
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-section-default uk-section-overlap uk-section",
|
||||||
|
},
|
||||||
|
titleClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
},
|
||||||
|
dividerClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-divider-small uk-text-left@m uk-text-center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schema cho content section - CẤU TRÚC MỚI
|
||||||
|
const contentSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
sectionClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
},
|
||||||
|
textClass: {
|
||||||
|
type: String,
|
||||||
|
trim: true,
|
||||||
|
default: "uk-panel uk-margin text-[1vw]",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: [contentItemSchema],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main Terms Schema - CẤU TRÚC MỚI
|
||||||
|
const termsSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "default",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
// CHỈ CÒN 3 PHẦN CHÍNH
|
||||||
|
hero: {
|
||||||
|
type: heroSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
type: pageSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: contentSchema,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: String,
|
||||||
|
default: "en",
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: String,
|
||||||
|
default: "2.0.0", // Tăng version vì cấu trúc thay đổi
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
migratedFromOldStructure: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Static method: Lấy terms default - CẬP NHẬT THEO CẤU TRÚC MỚI
|
||||||
|
termsSchema.statics.getDefault = async function(language = "en") {
|
||||||
|
try {
|
||||||
|
let terms = await this.findOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
if (!terms) {
|
||||||
|
// Tạo terms mặc định theo cấu trúc mới
|
||||||
|
terms = new this({
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
hero: {
|
||||||
|
title: "Frequently Asked Questions",
|
||||||
|
subtitle: "Our Terms & Conditions",
|
||||||
|
backgroundImage: "/uploads/terms/faqimage.jpg",
|
||||||
|
sectionClass: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
backgroundClasses: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
overlayStyle: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
titleClass: "text-white text-[5vw] uk-text-center",
|
||||||
|
subtitleClass: "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
|
||||||
|
enableScrollspy: true
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
title: "Terms & Conditions Go and Grow Camp e.K.",
|
||||||
|
divider: true,
|
||||||
|
sectionClass: "uk-section-default uk-section-overlap uk-section",
|
||||||
|
titleClass: "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
dividerClass: "uk-divider-small uk-text-left@m uk-text-center"
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
sectionClass: "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
textClass: "uk-panel uk-margin text-[1vw]",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "This is an English translation of the original and legally binding German document \"Allgemeine Geschäftsbedingungen Go and Grow Camp e.K.\", which can be viewed at <a href=\"https://www.campadventure.de/de/infos/agb\" target=\"_self\">https://www.campadventure.de/de/infos/agb</a>. This translation is for your information only and is not legally binding."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "<strong>Go and Grow Camp e.K. is the tour operator for individuals, for camps in Germany, England and Northern Ireland.</strong>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "GUARANTEE: All participants are protected in accordance with the legal regulations governing tour operators in Germany. As per §651, any payments made towards the travel price are insured against insolvency by tourVers."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
migratedFromOldStructure: false
|
||||||
|
});
|
||||||
|
|
||||||
|
await terms.save();
|
||||||
|
console.log(`Created default terms for language: ${language} (new structure)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return terms;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in getDefault:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method để get terms data
|
||||||
|
termsSchema.methods.getTermsData = function() {
|
||||||
|
return this.toObject();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Migration method từ JSON CŨ sang cấu trúc MỚI
|
||||||
|
termsSchema.statics.migrateFromJson = async function(jsonData, language = "en") {
|
||||||
|
try {
|
||||||
|
console.log('Migrating from JSON to new structure...');
|
||||||
|
|
||||||
|
// Xóa document cũ nếu có
|
||||||
|
await this.deleteOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
// Chuyển đổi từ cấu trúc cũ sang mới
|
||||||
|
const processedData = {
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
migratedFromOldStructure: true,
|
||||||
|
|
||||||
|
hero: {
|
||||||
|
title: jsonData.hero?.title || "Go and Grow Camp",
|
||||||
|
subtitle: jsonData.hero?.subtitle || "Our Terms & Conditions",
|
||||||
|
backgroundImage: jsonData.hero?.backgroundImage || "/uploads/terms/faqimage.jpg",
|
||||||
|
sectionClass: "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
backgroundClasses: "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
overlayStyle: {
|
||||||
|
backgroundColor: jsonData.hero?.overlayColor || "rgba(0, 0, 0, 0)"
|
||||||
|
},
|
||||||
|
titleClass: "text-white text-[5vw] uk-text-center",
|
||||||
|
subtitleClass: "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
|
||||||
|
enableScrollspy: jsonData.hero?.enableScrollspy || true
|
||||||
|
},
|
||||||
|
|
||||||
|
page: {
|
||||||
|
title: jsonData.termsHeader?.title || "Terms & Conditions Go and Grow Camp e.K.",
|
||||||
|
divider: jsonData.termsHeader?.divider !== false,
|
||||||
|
sectionClass: jsonData.termsHeader?.sectionClass || "uk-section-default uk-section-overlap uk-section",
|
||||||
|
titleClass: jsonData.termsHeader?.titleClass || "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
dividerClass: jsonData.termsHeader?.dividerClass || "uk-divider-small uk-text-left@m uk-text-center"
|
||||||
|
},
|
||||||
|
|
||||||
|
content: {
|
||||||
|
sectionClass: jsonData.layout?.termsSectionClass || "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
textClass: jsonData.layout?.textContentClass || "uk-panel uk-margin text-[1vw]",
|
||||||
|
content: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chuyển đổi sections cũ sang content mới
|
||||||
|
const contentItems = [];
|
||||||
|
|
||||||
|
// Thêm disclaimer đầu tiên nếu có
|
||||||
|
if (jsonData.disclaimer?.text) {
|
||||||
|
contentItems.push({
|
||||||
|
type: "paragraph",
|
||||||
|
text: jsonData.disclaimer.text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.disclaimer?.importantNote) {
|
||||||
|
contentItems.push({
|
||||||
|
type: "paragraph",
|
||||||
|
text: `<strong>${jsonData.disclaimer.importantNote}</strong>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonData.disclaimer?.legalNote) {
|
||||||
|
contentItems.push({
|
||||||
|
type: "paragraph",
|
||||||
|
text: jsonData.disclaimer.legalNote
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thêm disclaimer note
|
||||||
|
if (jsonData.disclaimer?.note) {
|
||||||
|
contentItems.push({
|
||||||
|
type: "paragraph",
|
||||||
|
text: jsonData.disclaimer.note
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thêm các sections
|
||||||
|
if (jsonData.sections && Array.isArray(jsonData.sections)) {
|
||||||
|
jsonData.sections.forEach(section => {
|
||||||
|
if (section.title && section.content) {
|
||||||
|
const contentItem = {
|
||||||
|
type: "section",
|
||||||
|
title: section.title,
|
||||||
|
content: section.content
|
||||||
|
};
|
||||||
|
|
||||||
|
// Thêm subsections nếu có
|
||||||
|
if (section.subsections && section.subsections.length > 0) {
|
||||||
|
contentItem.subsections = section.subsections.map(sub => ({
|
||||||
|
type: "note",
|
||||||
|
text: sub.content || sub
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thêm cancellation fees nếu có
|
||||||
|
if (section.fees) {
|
||||||
|
contentItem.subsections = contentItem.subsections || [];
|
||||||
|
|
||||||
|
// Individual fees
|
||||||
|
if (section.fees.individual && section.fees.individual.length > 0) {
|
||||||
|
contentItem.subsections.push({
|
||||||
|
type: "cancellation_table",
|
||||||
|
title: "Standard Cancellation Fees",
|
||||||
|
items: section.fees.individual.map(fee => `${fee.period} – ${fee.fee}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// School group fees
|
||||||
|
if (section.fees.schoolGroups && section.fees.schoolGroups.fees) {
|
||||||
|
contentItem.subsections.push({
|
||||||
|
type: "cancellation_section",
|
||||||
|
title: "Cancellation policy for school groups:",
|
||||||
|
items: [
|
||||||
|
section.fees.schoolGroups.freeCorrection,
|
||||||
|
...section.fees.schoolGroups.fees.map(fee => `${fee.period}: ${fee.fee}`)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee note
|
||||||
|
if (section.fees.note) {
|
||||||
|
contentItem.subsections.push({
|
||||||
|
type: "note",
|
||||||
|
text: section.fees.note
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItems.push(contentItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thêm footer note nếu có
|
||||||
|
if (jsonData.footerNote?.text) {
|
||||||
|
contentItems.push({
|
||||||
|
type: "paragraph",
|
||||||
|
text: jsonData.footerNote.text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gán content items đã chuyển đổi
|
||||||
|
processedData.content.content = contentItems;
|
||||||
|
|
||||||
|
// Tạo document mới
|
||||||
|
const newTerms = await this.create(processedData);
|
||||||
|
console.log(`Terms data migrated to new structure for language: ${language}`);
|
||||||
|
console.log(`Total content items: ${contentItems.length}`);
|
||||||
|
|
||||||
|
return newTerms;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error migrating terms data to new structure:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Migration method từ cấu trúc MỚI sang cấu trúc MỚI (dành cho JSON mới)
|
||||||
|
termsSchema.statics.migrateFromNewJson = async function(jsonData, language = "en") {
|
||||||
|
try {
|
||||||
|
console.log('Migrating from new JSON structure...');
|
||||||
|
|
||||||
|
// Xóa document cũ nếu có
|
||||||
|
await this.deleteOne({ name: "default", language: language });
|
||||||
|
|
||||||
|
// Tạo document mới với cấu trúc mới
|
||||||
|
const newTerms = await this.create({
|
||||||
|
name: "default",
|
||||||
|
language: language,
|
||||||
|
version: "2.0.0",
|
||||||
|
isActive: true,
|
||||||
|
migratedFromOldStructure: false,
|
||||||
|
|
||||||
|
hero: {
|
||||||
|
title: jsonData.hero?.title || "Go and Grow Camp",
|
||||||
|
subtitle: jsonData.hero?.subtitle || "Our Terms & Conditions",
|
||||||
|
backgroundImage: jsonData.hero?.backgroundImage || "/uploads/terms/faqimage.jpg",
|
||||||
|
sectionClass: jsonData.hero?.sectionClass || "uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative",
|
||||||
|
backgroundClasses: jsonData.hero?.backgroundClasses || "uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge",
|
||||||
|
overlayStyle: jsonData.hero?.overlayStyle || { backgroundColor: "rgba(0, 0, 0, 0)" },
|
||||||
|
titleClass: jsonData.hero?.titleClass || "text-white text-[5vw] uk-text-center",
|
||||||
|
subtitleClass: jsonData.hero?.subtitleClass || "uk-panel font-[Raleway] italic text-[1.5vw] uk-margin uk-text-center",
|
||||||
|
enableScrollspy: jsonData.hero?.enableScrollspy !== undefined ? jsonData.hero.enableScrollspy : true
|
||||||
|
},
|
||||||
|
|
||||||
|
page: {
|
||||||
|
title: jsonData.page?.title || "Terms & Conditions Go and Grow Camp e.K.",
|
||||||
|
divider: jsonData.page?.divider !== undefined ? jsonData.page.divider : true,
|
||||||
|
sectionClass: jsonData.page?.sectionClass || "uk-section-default uk-section-overlap uk-section",
|
||||||
|
titleClass: jsonData.page?.titleClass || "text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center",
|
||||||
|
dividerClass: jsonData.page?.dividerClass || "uk-divider-small uk-text-left@m uk-text-center"
|
||||||
|
},
|
||||||
|
|
||||||
|
content: {
|
||||||
|
sectionClass: jsonData.content?.sectionClass || "uk-section-muted uk-section-overlap uk-section",
|
||||||
|
textClass: jsonData.content?.textClass || "uk-panel uk-margin text-[1vw]",
|
||||||
|
content: jsonData.content?.content || []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Terms data created with new structure for language: ${language}`);
|
||||||
|
console.log(`Hero title: ${newTerms.hero.title}`);
|
||||||
|
console.log(`Page title: ${newTerms.page.title}`);
|
||||||
|
console.log(`Content items: ${newTerms.content.content.length}`);
|
||||||
|
|
||||||
|
return newTerms;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating terms data from new structure:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Terms = mongoose.model("Terms", termsSchema);
|
||||||
|
|
||||||
|
module.exports = Terms;
|
||||||
45
models/travel.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const travelSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
page: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Travel Information",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
year: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "Travel Information",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: mongoose.Schema.Types.Mixed,
|
||||||
|
default: { blocks: [] },
|
||||||
|
},
|
||||||
|
enableScrollspy: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Travel", travelSchema);
|
||||||
0
models/visa.js
Normal file
2891
package-lock.json
generated
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "cms-sims",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "CMS",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"migrate": "node scripts/migrate-all.js",
|
||||||
|
"migrate-fresh": "node scripts/migrate-fresh.js",
|
||||||
|
"migrate-status": "node scripts/migrate-status.js",
|
||||||
|
"migrate-rollback": "node scripts/migrate-rollback.js",
|
||||||
|
"make-migration": "node scripts/make-migration.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"cms",
|
||||||
|
"nodejs",
|
||||||
|
"express"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/editorjs": "^2.30.8",
|
||||||
|
"@editorjs/header": "^2.8.8",
|
||||||
|
"@editorjs/image": "^2.10.3",
|
||||||
|
"@editorjs/list": "^2.0.8",
|
||||||
|
"@editorjs/marker": "^1.4.0",
|
||||||
|
"@editorjs/paragraph": "^2.11.7",
|
||||||
|
"@editorjs/quote": "^2.7.6",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"connect-flash": "^0.1.1",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"ejs": "^3.1.9",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-ejs-layouts": "^2.5.1",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"i18n": "^0.15.1",
|
||||||
|
"mongoose": "^8.16.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"passport": "^0.6.0",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
|
"slugify": "^1.6.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^2.0.22"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/img/close.png
Normal file
|
After Width: | Height: | Size: 232 B |
BIN
public/img/favicon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/img/header/home-1.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/img/header/home-2.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/img/header/home-3.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/img/home-1/about/Vector.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/img/home-1/about/about-02.jpg
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/img/home-1/about/about-1.jpg
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/img/home-1/about/globe.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/img/home-1/about/plane.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/img/home-1/about/shape.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/home-1/brand/01.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/img/home-1/brand/02.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/img/home-1/brand/03.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/img/home-1/brand/04.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/img/home-1/brand/05.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/img/home-1/feature/Vector.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/home-1/feature/bg-2.jpg
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/img/home-1/feature/bg.png
Normal file
|
After Width: | Height: | Size: 826 KiB |
BIN
public/img/home-1/feature/icon-1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/img/home-1/feature/icon-2.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/img/home-1/feature/icon-3.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/img/home-1/feature/icon-4.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/img/home-1/feature/shape-2.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/img/home-1/feature/shape.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/img/home-1/feature/text-2.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
public/img/home-1/feature/text.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
public/img/home-1/feature/video-bg.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/img/home-1/footer-bg.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |