diff --git a/assets/css/components/button.css b/assets/css/components/button.css new file mode 100644 index 0000000..cd54ae7 --- /dev/null +++ b/assets/css/components/button.css @@ -0,0 +1,127 @@ +/** + * CMS Component: Buttons + * Overrides Bootstrap buttons to match CMS design language + */ + +.btn { + border-radius: var(--border-radius); + font-weight: var(--font-weight-medium); + padding: 0.5rem 1.25rem; + transition: var(--transition-base); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 42px; /* Standardize height */ +} + +.btn-sm { + padding: 0.25rem 0.75rem; + font-size: var(--font-size-sm); + height: 32px; +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: var(--font-size-lg); + height: 52px; +} + +/* Primary Button */ +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); + color: var(--text-white); +} + +.btn-primary:hover, .btn-primary:focus { + background-color: var(--primary-dark); + border-color: var(--primary-dark); + color: var(--text-white); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +/* Outline Primary */ +.btn-outline-primary { + color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-outline-primary:hover { + background-color: var(--primary-color); + color: var(--text-white); +} + +/* White / Icon Button */ +.btn-white { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-main); +} + +.btn-white:hover { + background-color: #f8f9fa; + border-color: #dee2e6; + box-shadow: var(--shadow-sm); +} + +/* Special Button Effects (Shine effect from main.ejs) */ +.btn-shine { + position: relative; + overflow: hidden; +} + +.btn-shine::after { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: all 0.5s ease; +} + +.btn-shine:hover::after { + left: 100%; +} + +/* Action Button Group (Like Topbar screenshot) */ +.btn-group-action { + background-color: #fff; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + display: inline-flex; +} + +.btn-group-action .btn { + border: none; + border-radius: 0; + padding: 0.5rem 0.85rem; + background: transparent; + height: 38px; + width: 42px; +} + +.btn-group-action .btn:not(:last-child) { + border-right: 1px solid var(--border-color); +} + +.btn-group-action .btn:hover { + background-color: #f8f9fa; + transform: none; + box-shadow: none; +} + +/* Icon alignment inside buttons */ +.btn i { + font-size: 0.9em; +} + +/* Icon specific colors for actions */ +.text-action-add { color: #0d6efd !important; } +.text-action-edit { color: #ffc107 !important; } +.text-action-delete { color: #dc3545 !important; } diff --git a/assets/css/components/card.css b/assets/css/components/card.css new file mode 100644 index 0000000..4765955 --- /dev/null +++ b/assets/css/components/card.css @@ -0,0 +1,39 @@ +/** + * CMS Component: Cards + * Standardizes administrative dashboard cards + */ + +.card { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); + transition: var(--transition-base); + overflow: hidden; +} + +.card:hover { + box-shadow: var(--shadow-md); +} + +.card-header { + background-color: #fff; + border-bottom: 1px solid var(--border-color); + padding: var(--spacing-3) var(--spacing-4); + font-weight: var(--font-weight-bold); +} + +.card-title { + margin-bottom: 0; + font-size: 1.1rem; + color: var(--text-main); +} + +.card-body { + padding: var(--spacing-4); +} + +.card-footer { + background-color: #fcfcfc; + border-top: 1px solid var(--border-color); + padding: var(--spacing-2) var(--spacing-4); +} diff --git a/assets/css/components/form.css b/assets/css/components/form.css new file mode 100644 index 0000000..069efd1 --- /dev/null +++ b/assets/css/components/form.css @@ -0,0 +1,50 @@ +/** + * CMS Component: Forms + * Standardizes inputs, labels and validation messages + */ + +.form-label { + font-weight: var(--font-weight-medium); + margin-bottom: var(--spacing-2); + color: var(--text-main); + font-size: var(--font-size-sm); +} + +.form-control, .form-select { + border-radius: var(--border-radius); + border: 1px solid var(--border-color); + padding: 0.6rem 1rem; + font-size: var(--font-size-base); + transition: var(--transition-base); +} + +.form-control:focus, .form-select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(188, 159, 105, 0.15); + outline: none; +} + +.input-group-text { + background-color: #f8f9fa; + border-color: var(--border-color); + color: var(--text-muted); +} + +/* Form Helper Text */ +.form-text { + font-size: var(--font-size-xs); + color: var(--text-muted); + margin-top: var(--spacing-1); +} + +/* Validation Styles */ +.invalid-feedback { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +/* Custom Checkbox/Radio */ +.form-check-input:checked { + background-color: var(--primary-color); + border-color: var(--primary-color); +} diff --git a/assets/css/components/modal.css b/assets/css/components/modal.css new file mode 100644 index 0000000..63b0062 --- /dev/null +++ b/assets/css/components/modal.css @@ -0,0 +1,45 @@ +/** + * CMS Component: Modals + * Standardizes modal spacing and appearance + */ + +.modal-content { + border: none; + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-lg); +} + +.modal-header { + border-bottom: 1px solid var(--border-color); + padding: var(--spacing-4); + background-color: #fff; + border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; +} + +.modal-title { + font-weight: var(--font-weight-bold); + color: var(--text-main); + font-size: 1.15rem; +} + +.modal-body { + padding: var(--spacing-4); +} + +.modal-footer { + border-top: 1px solid var(--border-color); + padding: var(--spacing-3) var(--spacing-4); + gap: var(--spacing-2); +} + +/* Modal sizing standards */ +.modal-dialog-centered { + display: flex; + align-items: center; + min-height: calc(100% - var(--bs-modal-margin) * 2); +} + +/* Fix for backdrop issue reported in previous conversations */ +.modal-backdrop.show { + opacity: 0.5; +} diff --git a/assets/css/components/table.css b/assets/css/components/table.css new file mode 100644 index 0000000..f77f7a5 --- /dev/null +++ b/assets/css/components/table.css @@ -0,0 +1,41 @@ +/** + * CMS Component: Tables + * Standardizes data tables listing + */ + +.table { + margin-bottom: 0; +} + +.table thead th { + background-color: #f8f9fa; + border-bottom: 2px solid var(--border-color); + color: var(--text-muted); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.025em; + padding: var(--spacing-3) var(--spacing-4); +} + +.table tbody td { + padding: var(--spacing-3) var(--spacing-4); + vertical-align: middle; + border-bottom: 1px solid var(--border-color); +} + +.table-hover tbody tr:hover { + background-color: rgba(188, 159, 105, 0.05); +} + +/* Badge in table */ +.badge { + padding: 0.4em 0.6em; + border-radius: var(--border-radius-sm); + font-weight: var(--font-weight-medium); +} + +.bg-soft-success { background-color: var(--success-soft); color: var(--success-color); border: 1px solid rgba(40, 167, 69, 0.2); } +.bg-soft-danger { background-color: var(--danger-soft); color: var(--danger-color); border: 1px solid rgba(220, 53, 69, 0.2); } +.bg-soft-warning { background-color: var(--warning-soft); color: var(--warning-color); border: 1px solid rgba(255, 193, 7, 0.2); } +.bg-soft-info { background-color: var(--info-soft); color: var(--info-color); border: 1px solid rgba(23, 162, 184, 0.2); } diff --git a/assets/css/layout.css b/assets/css/layout.css new file mode 100644 index 0000000..4b85907 --- /dev/null +++ b/assets/css/layout.css @@ -0,0 +1,101 @@ +/** + * CMS Global Layout + * Navbar, Footer, and Page structures + */ + +body { + font-family: var(--font-family); + background-color: var(--bg-body); + min-height: 100vh; + display: flex; + flex-direction: column; + color: var(--text-main); +} + +main { + flex: 1; + padding: var(--spacing-4) 0; +} + +/* Navbar Customization */ +.navbar { + background-color: rgba(255, 255, 255, 0.95); + box-shadow: var(--shadow-header); + padding: 0.75rem 0; + transition: var(--transition-base); +} + +.navbar-brand { + font-weight: var(--font-weight-bold); + color: var(--primary-color) !important; +} + +.nav-link { + color: var(--text-main); + font-weight: var(--font-weight-medium); + padding: 0.5rem 1rem; + transition: var(--transition-base); + font-size: 0.95rem; +} + +.nav-link:hover, .nav-link.active { + color: var(--primary-color) !important; +} + +/* Page Containers */ +.page-header { + margin-bottom: var(--spacing-4); +} + +.page-content { + background-color: var(--bg-card); + padding: var(--spacing-4); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); +} + +/* Footer Customization */ +footer { + background: linear-gradient(90deg, var(--primary-dark), var(--primary-color), var(--primary-light), #d4c4a8); + color: var(--text-white); + margin-top: auto; + padding: var(--spacing-4) 0; +} + +footer a { + color: rgba(255, 255, 255, 0.8) !important; + text-decoration: none; + transition: var(--transition-base); +} + +footer a:hover { + color: var(--text-white) !important; +} + +/* Utility: Fixed Bottom Actions (for Forms/Modals) */ +.fixed-bottom-buttons { + position: fixed; + bottom: 0; + right: 0; + padding: var(--spacing-3); + z-index: 1000; + display: flex; + gap: var(--spacing-2); +} + +/* Dropdown Customization */ +.dropdown-menu { + border: none; + box-shadow: var(--shadow-lg); + border-radius: var(--border-radius); + margin-top: 0.5rem; +} + +.dropdown-item:hover { + background-color: var(--primary-soft); +} + +.dropdown-item.active { + background-color: var(--primary-color); + color: var(--text-white); +} diff --git a/assets/css/variables.css b/assets/css/variables.css new file mode 100644 index 0000000..568b5b9 --- /dev/null +++ b/assets/css/variables.css @@ -0,0 +1,67 @@ +/** + * CMS Design System Variables + * Standardized colors, spacing, and typography + */ + +:root { + /* Primary Colors (Gold/Cinnamon) */ + --primary-color: #bc9f69; + --primary-rgb: 188, 159, 105; + --primary-light: #d4c4a8; + --primary-dark: #a68b58; + --primary-soft: rgba(188, 159, 105, 0.1); + + /* Secondary Colors */ + --secondary-color: #6c757d; + --secondary-soft: rgba(108, 117, 125, 0.1); + + /* Status Colors */ + --success-color: #28a745; + --success-soft: rgba(40, 167, 69, 0.1); + --warning-color: #ffc107; + --warning-soft: rgba(255, 193, 7, 0.1); + --danger-color: #dc3545; + --danger-soft: rgba(220, 53, 69, 0.1); + --info-color: #17a2b8; + --info-soft: rgba(23, 162, 184, 0.1); + + /* Neutral Colors / Backgrounds */ + --bg-body: #f5f7fa; + --bg-card: #ffffff; + --bg-header: #ffffff; + --border-color: #e9ecef; + --text-main: #333333; + --text-muted: #6c757d; + --text-white: #ffffff; + + /* Typography */ + --font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + --font-size-base: 1rem; + --font-size-sm: 0.875rem; + --font-size-xs: 0.75rem; + --font-size-lg: 1.25rem; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-bold: 700; + + /* Spacing & Borders */ + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 1rem; + --spacing-4: 1.5rem; + --spacing-5: 3rem; + + --border-radius: 8px; + --border-radius-sm: 4px; + --border-radius-lg: 12px; + --border-radius-full: 50px; + + /* Shadows & Elevation */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-header: 0 2px 10px rgba(0, 0, 0, 0.05); + + /* Transitions */ + --transition-base: all 0.2s ease-in-out; +} diff --git a/controllers/headerController.js b/controllers/headerController.js index c4e3183..85a0d4d 100644 --- a/controllers/headerController.js +++ b/controllers/headerController.js @@ -1,347 +1,367 @@ -const { addBaseUrlToImages } = require('../utils/imageHelper'); -const Header = require('../models/header'); -const Menu = require('../models/menuHeader'); +const Header = require("../models/header"); +const HeaderMenu = require("../models/HeaderMenu"); -// 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 to build a tree structure (Mirroring logic in headerMenuController) + */ +const buildTree = (items, parentId = null) => { + const branch = []; + const children = items.filter(item => + String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null) + ); -// 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); + for (const child of children) { + const item = child.toObject ? child.toObject() : { ...child }; + const subChildren = buildTree(items, item._id); + item.children = subChildren.length > 0 ? subChildren : []; + branch.push(item); } - return processedItem; - }); + return branch.sort((a, b) => a.order - b.order); }; -// 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 +// Admin: Render header management page exports.index = async (req, res) => { - try { - const data = await getHeaderData(); + try { + const header = await Header.findOne().sort({ order: 1 }); - 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 + // Prepare data for view + const data = header + ? { + topbar: { + contactInfo: { + phone: header.top?.phone || "", + email: header.top?.email || "", + location: header.top?.location || "", + }, + socialLinks: header.top?.socialLinks || [], + }, + logo: header.logo?.light || "", + } + : { + topbar: { + contactInfo: { + phone: "", + email: "", + location: "", + }, + socialLinks: [], + }, + logo: "", }; - // Add fetch field if provided - if (fetch !== undefined) { - updateData.fetch = fetch; - } + const activeTab = req.query.tab || "topbar"; + + // Always fetch menu items to ensure they are available even if the user + // switches tabs client-side + const items = await HeaderMenu.find().sort({ order: 1 }); + const menuData = { + flat: items, + tree: buildTree(items) + }; - // 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); - } + res.render("admin/header/index", { + layout: "layouts/main", + title: "Header Management", + user: req.session.user || null, + data: data, + activeTab: activeTab, + menuData: menuData + }); + } catch (error) { + console.error("Error loading header management:", error); + res.status(500).render("page/error", { + title: "Error", + message: "Failed to load header management page", + }); } - - 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' - }); +// Admin: Get all headers (API) +exports.getAll = async (req, res) => { + try { + const headers = await Header.find().sort({ order: 1 }); + res.json({ + success: true, + data: headers, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); } +}; - 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 } - ); +// Admin: Get single header +exports.show = async (req, res) => { + try { + const header = await Header.findById(req.params.id); + if (!header) { + return res.status(404).json({ + success: false, + message: "Header not found", + }); + } + res.json({ + success: true, + data: header, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); } - 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'); - } -}; \ No newline at end of file +}; + +// Admin: Create header +exports.store = async (req, res) => { + try { + const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body; + + const header = new Header({ + top, + offcanvas, + menu, + logo, + ctaButton, + status: status || "active", + order: order || 1, + }); + + await header.save(); + res.status(201).json({ + success: true, + message: "Header created successfully", + data: header, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// Admin: Update header +exports.update = async (req, res) => { + try { + let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } = req.body; + + console.log("=== UPDATE REQUEST RECEIVED ==="); + console.log("Raw body:", JSON.stringify(req.body, null, 2)); + console.log("topbarJson type:", typeof topbarJson); + console.log("topbarJson value:", topbarJson); + + // Nếu có topbarJson, parse nó + if (topbarJson && typeof topbarJson === "string") { + try { + const parsedData = JSON.parse(topbarJson); + console.log("✓ Parsed topbarJson successfully:", parsedData); + // Chuyển đổi từ topbarData sang top format + top = { + phone: parsedData.contactInfo?.phone || "", + email: parsedData.contactInfo?.email || "", + location: parsedData.contactInfo?.location || "", + socialLinks: parsedData.socialLinks || [], + }; + console.log("✓ Converted to top object:", top); + } catch (e) { + console.error("✗ Error parsing topbarJson:", e.message); + return res.status(400).json({ + success: false, + message: "Invalid JSON in topbarJson: " + e.message, + }); + } + } + + // Nếu không có id, tìm header đầu tiên hoặc tạo mới + let headerId = req.params.id; + + if (!headerId) { + // Tìm header đầu tiên + let header = await Header.findOne().sort({ order: 1 }); + if (!header) { + console.log("No existing header found, creating new one"); + // Tạo header mới nếu chưa có + header = new Header({ + top, + offcanvas, + menu, + logo: logo ? { light: logo } : {}, + ctaButton, + status: status || "active", + order: order || 1, + }); + await header.save(); + console.log("✓ Header created:", header._id); + return res.json({ + success: true, + message: "Header created successfully", + data: header, + }); + } + headerId = header._id; + console.log("✓ Found existing header:", headerId); + } + + // Chuẩn bị dữ liệu logo - merge với dữ liệu cũ + let logoData = {}; + if (logo) { + // Nếu có logo mới, lấy dữ liệu cũ và update light + const existingHeader = await Header.findById(headerId); + logoData = { + light: logo, + dark: existingHeader?.logo?.dark || "", + alt: existingHeader?.logo?.alt || "", + }; + } + + const updateData = { + top, + offcanvas, + menu, + ctaButton, + status, + order, + }; + + if (logo) { + updateData.logo = logoData; + } + + console.log("Preparing to update header with data:", JSON.stringify(updateData, null, 2)); + + const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, { new: true, runValidators: true }); + + if (!updatedHeader) { + console.error("✗ Header not found with ID:", headerId); + return res.status(404).json({ + success: false, + message: "Header not found", + }); + } + + console.log("✓ Header updated successfully:", updatedHeader._id); + console.log("Updated header data:", JSON.stringify(updatedHeader, null, 2)); + + res.json({ + success: true, + message: "Header updated successfully", + data: updatedHeader, + }); + } catch (error) { + console.error("✗ Error updating header:", error); + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// Admin: Update status +exports.updateStatus = async (req, res) => { + try { + const { status } = req.body; + + if (!["active", "inactive"].includes(status)) { + return res.status(400).json({ + success: false, + message: "Invalid status", + }); + } + + const header = await Header.findByIdAndUpdate(req.params.id, { status }, { new: true }); + + if (!header) { + return res.status(404).json({ + success: false, + message: "Header not found", + }); + } + + res.json({ + success: true, + message: "Header status updated", + data: header, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +// Admin: Delete header +exports.destroy = async (req, res) => { + try { + const header = await Header.findByIdAndDelete(req.params.id); + + if (!header) { + return res.status(404).json({ + success: false, + message: "Header not found", + }); + } + + res.json({ + success: true, + message: "Header deleted successfully", + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +// Public API: Get active header +exports.api = async (req, res) => { + try { + const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + res.json({ + success: true, + data: header, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +// Public API: Get menu tree structure +exports.getMenuTreeAPI = async (req, res) => { + try { + const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header || !header.menu) { + return res.status(404).json({ + success: false, + message: "No active menu found", + }); + } + + res.json({ + success: true, + data: header.menu, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/controllers/headerMenuController.js b/controllers/headerMenuController.js new file mode 100644 index 0000000..1ab91b5 --- /dev/null +++ b/controllers/headerMenuController.js @@ -0,0 +1,175 @@ +const HeaderMenu = require("../models/HeaderMenu"); +const slugify = require("slugify"); + +/** + * Helper: Build tree structure from flat array + */ +const buildMenuTree = (items, parentId = null, isPublic = false) => { + const branch = []; + const children = items.filter(item => + String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null) + ); + + for (const child of children) { + const item = child.toObject ? child.toObject() : { ...child }; + + // Clean data for public API if requested + let cleanItem = item; + if (isPublic) { + cleanItem = { + id: item._id, + title: item.title, + url: item.url, + type: item.type + }; + } + + const subChildren = buildMenuTree(items, item._id, isPublic); + cleanItem.children = subChildren.length > 0 ? subChildren : []; + branch.push(cleanItem); + } + return branch.sort((a, b) => a.order - b.order); +}; + +/** + * Helper: Recursive delete children + */ +const deleteRecursive = async (parentId) => { + const children = await HeaderMenu.find({ parentId }); + for (const child of children) { + await deleteRecursive(child._id); + await HeaderMenu.findByIdAndDelete(child._id); + } +}; + +// 1. Render Menu Tab logic (called from Header index or directly if route allows) +exports.renderMenuTab = async (req, res) => { + try { + const items = await HeaderMenu.find().sort({ order: 1 }); + const menuTree = buildMenuTree(items); + return { menuTree, flatItems: items }; + } catch (error) { + console.error("Error fetching menu items:", error); + throw error; + } +}; + +// 2. Create Menu Item +exports.createMenu = async (req, res) => { + try { + console.log('=== BACKEND: createMenu hit ==='); + console.log('Body:', req.body); + const { title, url, parentId, order, status, type } = req.body; + const slug = slugify(title, { lower: true, strict: true }); + + const newItem = new HeaderMenu({ + title, + slug, + url, + parentId: parentId || null, + order: order || 0, + status: status || "active", + type: type || "internal" + }); + + const savedItem = await newItem.save(); + console.log('=== MENU CREATED ===', savedItem); + req.flash("success_msg", "Menu item created successfully"); + res.redirect("/admin/header?tab=menu"); + } catch (error) { + console.error('=== CREATE MENU ERROR ===', error); + req.flash("error_msg", "Failed to create menu item: " + error.message); + res.redirect("/admin/header?tab=menu"); + } +}; + +// 3. Update Menu Item +exports.updateMenu = async (req, res) => { + try { + const { id } = req.params; + console.log('=== BACKEND: updateMenu hit ===', { id }); + console.log('Body:', req.body); + const { title, url, parentId, order, status, type } = req.body; + + const updateData = { + url, + parentId: parentId || null, + order, + status, + type + }; + + if (title) { + updateData.title = title; + updateData.slug = slugify(title, { lower: true, strict: true }); + } + + const updated = await HeaderMenu.findByIdAndUpdate(id, updateData, { new: true }); + + if (!updated) { + console.log('=== UPDATE MENU NOT FOUND ===', id); + req.flash("error_msg", "Menu item not found"); + } else { + console.log('=== MENU UPDATED ===', updated); + req.flash("success_msg", "Menu item updated successfully"); + } + res.redirect("/admin/header?tab=menu"); + } catch (error) { + console.error('=== UPDATE MENU ERROR ===', error); + req.flash("error_msg", "Update failed: " + error.message); + res.redirect("/admin/header?tab=menu"); + } +}; + +// 4. Delete Menu Item (Cascade delete children) +exports.deleteMenu = async (req, res) => { + try { + const { id } = req.body; + const menuId = id || req.params.id; + console.log('=== BACKEND: deleteMenu hit ===', { menuId, body: req.body }); + + await deleteRecursive(menuId); + await HeaderMenu.findByIdAndDelete(menuId); + + console.log('=== MENU DELETED ===', menuId); + req.flash("success_msg", "Menu item and its sub-menu deleted successfully"); + res.redirect("/admin/header?tab=menu"); + } catch (error) { + console.error('=== DELETE MENU ERROR ===', error); + req.flash("error_msg", "Delete failed: " + error.message); + res.redirect("/admin/header?tab=menu"); + } +}; + +// 5. Reorder Menu +exports.reorderMenu = async (req, res) => { + try { + const { items } = req.body; // Array of { id, order, parentId } + + if (items && Array.isArray(items)) { + const bulkOps = items.map(item => ({ + updateOne: { + filter: { _id: item.id }, + update: { order: item.order, parentId: item.parentId || null } + } + })); + await HeaderMenu.bulkWrite(bulkOps); + return res.json({ success: true, message: "Reordered successfully" }); + } + + res.status(400).json({ success: false, message: "Invalid data" }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; + +// Public API: Get active menu as clean tree +exports.api = async (req, res) => { + try { + const items = await HeaderMenu.find({ status: "active" }).sort({ order: 1 }); + const tree = buildMenuTree(items, null, true); + res.json({ success: true, data: tree }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/controllers/socialLinkController.js b/controllers/socialLinkController.js new file mode 100644 index 0000000..8c71fae --- /dev/null +++ b/controllers/socialLinkController.js @@ -0,0 +1,321 @@ +const Header = require("../models/header"); + +// Get all social links +exports.getAll = async (req, res) => { + try { + const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + res.json({ + success: true, + data: header.top?.socialLinks || [], + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +// Get single social link by platform +exports.show = async (req, res) => { + try { + const { platform } = req.params; + const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform); + + if (!socialLink) { + return res.status(404).json({ + success: false, + message: "Social link not found", + }); + } + + res.json({ + success: true, + data: socialLink, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +// Create social link +exports.store = async (req, res) => { + try { + let { platform, url, icon } = req.body; + + // Convert platform to lowercase + platform = platform.toLowerCase().trim(); + url = url.trim(); + icon = icon ? icon.trim() : null; + + console.log("Creating social link:", { platform, url, icon }); + + // Validate required fields + if (!platform || !url) { + console.log("Validation failed: platform or url missing"); + return res.status(400).json({ + success: false, + message: "Platform and URL are required", + }); + } + + // Validate platform is in enum + const validPlatforms = ["linkedin", "twitter", "instagram", "youtube", "facebook"]; + if (!validPlatforms.includes(platform)) { + console.log("Invalid platform:", platform); + return res.status(400).json({ + success: false, + message: `Invalid platform. Must be one of: ${validPlatforms.join(", ")}`, + }); + } + + // Find header + let header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + console.log("No active header found"); + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + console.log("Found header:", header._id); + + // Check if platform already exists + const existingLink = header.top?.socialLinks?.find((link) => link.platform === platform); + + if (existingLink) { + console.log("Platform already exists:", platform); + return res.status(400).json({ + success: false, + message: `Social link for ${platform} already exists`, + }); + } + + // Add new social link + if (!header.top) { + header.top = {}; + } + if (!header.top.socialLinks) { + header.top.socialLinks = []; + } + + // Calculate next order number + const maxOrder = + header.top.socialLinks.length > 0 ? Math.max(...header.top.socialLinks.map((link) => link.order || 0)) : 0; + + header.top.socialLinks.push({ + platform, + url, + icon: icon || `fa-brands fa-${platform}`, + order: maxOrder + 1, + }); + + console.log("Saving header with new social link"); + await header.save(); + + console.log("Social link created successfully"); + res.status(201).json({ + success: true, + message: "Social link created successfully", + data: header.top.socialLinks[header.top.socialLinks.length - 1], + }); + } catch (error) { + console.error("Error creating social link:", error); + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// Update social link +exports.update = async (req, res) => { + try { + let { platform } = req.params; + let { url, icon } = req.body; + + // Convert to lowercase + platform = platform.toLowerCase().trim(); + url = url.trim(); + icon = icon ? icon.trim() : null; + + // Validate required fields + if (!url) { + return res.status(400).json({ + success: false, + message: "URL is required", + }); + } + + // Find header + const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + // Find and update social link + const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform); + + if (!socialLink) { + return res.status(404).json({ + success: false, + message: "Social link not found", + }); + } + + socialLink.url = url; + if (icon) { + socialLink.icon = icon; + } + + await header.save(); + + res.json({ + success: true, + message: "Social link updated successfully", + data: socialLink, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; + +// Delete social link +exports.destroy = async (req, res) => { + try { + let { platform } = req.params; + + // Convert to lowercase + platform = platform.toLowerCase().trim(); + + console.log("Deleting social link:", platform); + + // Find header + const header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + console.log("No active header found"); + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + // Find and remove social link + const index = header.top?.socialLinks?.findIndex((link) => link.platform === platform); + + if (index === -1 || index === undefined) { + console.log("Social link not found:", platform); + return res.status(404).json({ + success: false, + message: "Social link not found", + }); + } + + const deletedLink = header.top.socialLinks.splice(index, 1); + + console.log("Saving header after delete"); + await header.save(); + + console.log("Social link deleted successfully"); + res.json({ + success: true, + message: "Social link deleted successfully", + data: deletedLink[0], + }); + } catch (error) { + console.error("Error deleting social link:", error); + res.status(500).json({ + success: false, + message: error.message, + }); + } +}; + +// Bulk update social links (used for reordering and batch updates) +exports.bulkUpdate = async (req, res) => { + try { + const { socialLinks } = req.body; + + if (!Array.isArray(socialLinks)) { + return res.status(400).json({ + success: false, + message: "socialLinks must be an array", + }); + } + + // Find header + let header = await Header.findOne({ status: "active" }).sort({ order: 1 }); + + if (!header) { + return res.status(404).json({ + success: false, + message: "No active header found", + }); + } + + // Validate all social links + for (const link of socialLinks) { + if (!link.platform || !link.url) { + return res.status(400).json({ + success: false, + message: "Each social link must have platform and url", + }); + } + } + + // Update social links with order field + if (!header.top) { + header.top = {}; + } + + header.top.socialLinks = socialLinks.map((link, index) => ({ + platform: link.platform, + url: link.url, + icon: link.icon || `fa-brands fa-${link.platform}`, + order: link.order || index + 1, // Use provided order or calculate from index + })); + + await header.save(); + + res.json({ + success: true, + message: "Social links updated successfully", + data: header.top.socialLinks, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } +}; diff --git a/data/header-menu.json b/data/header-menu.json new file mode 100644 index 0000000..496291b --- /dev/null +++ b/data/header-menu.json @@ -0,0 +1,184 @@ +[ + { + "label": "Home", + "slug": "home", + "href": "/", + "type": "internal", + "order": 1, + "isActive": true, + "children": [] + }, + { + "label": "About Us", + "slug": "about-us", + "href": "/about", + "type": "internal", + "order": 2, + "isActive": true, + "children": [] + }, + { + "label": "Pages", + "slug": "pages", + "href": "#", + "type": "internal", + "order": 3, + "isActive": true, + "children": [ + { + "label": "Services", + "slug": "services", + "href": "/services", + "type": "internal", + "order": 1, + "isActive": true, + "children": [ + { + "label": "Service List", + "slug": "service-list", + "href": "/service", + "type": "internal", + "order": 1, + "isActive": true + }, + { + "label": "Service Details", + "slug": "service-details", + "href": "/service-details", + "type": "internal", + "order": 2, + "isActive": true + } + ] + }, + { + "label": "Country List", + "slug": "country-list", + "href": "/country-list", + "type": "internal", + "order": 2, + "isActive": true, + "children": [ + { + "label": "Country List", + "slug": "country-list-all", + "href": "/country-list", + "type": "internal", + "order": 1, + "isActive": true + }, + { + "label": "Country Details", + "slug": "country-details", + "href": "/country-details", + "type": "internal", + "order": 2, + "isActive": true + } + ] + }, + { + "label": "Our Pricing", + "slug": "pricing", + "href": "/pricing", + "type": "internal", + "order": 3, + "isActive": true + }, + { + "label": "Appointment", + "slug": "appointment", + "href": "/appointment", + "type": "internal", + "order": 4, + "isActive": true + }, + { + "label": "FAQ", + "slug": "faq", + "href": "/faq", + "type": "internal", + "order": 5, + "isActive": true + } + ] + }, + { + "label": "VISA", + "slug": "visa", + "href": "#", + "type": "internal", + "order": 4, + "isActive": true, + "children": [ + { + "label": "Visa List", + "slug": "visa-list", + "href": "/visa-list", + "type": "internal", + "order": 1, + "isActive": true + }, + { + "label": "Visa Details", + "slug": "visa-details", + "href": "/visa-details", + "type": "internal", + "order": 2, + "isActive": true + } + ] + }, + { + "label": "Blog", + "slug": "blog", + "href": "#", + "type": "internal", + "order": 5, + "isActive": true, + "children": [ + { + "label": "Blog Grid", + "slug": "blog-grid", + "href": "/blog-grid", + "type": "internal", + "order": 1, + "isActive": true + }, + { + "label": "Blog Standard", + "slug": "blog-standard", + "href": "/blog", + "type": "internal", + "order": 2, + "isActive": true + }, + { + "label": "Blog Details", + "slug": "blog-details", + "href": "/blog-details", + "type": "internal", + "order": 3, + "isActive": true + } + ] + }, + { + "label": "Contact Us", + "slug": "contact-us", + "href": "/contact", + "type": "internal", + "order": 6, + "isActive": true, + "children": [] + }, + { + "label": "External Portal", + "slug": "external-portal", + "href": "https://partner.hailearning.edu.vn", + "type": "external", + "order": 7, + "isActive": false, + "children": [] + } +] diff --git a/data/header.json b/data/header.json index 4c09c54..925b0b4 100644 --- a/data/header.json +++ b/data/header.json @@ -1,24 +1,52 @@ { - "topbar": { - "contactInfo": { - "phone": "+(123)-456-789", - "email": "info@ggcamp.com" + "top": { + "phone": "+09 378 357 5222", + "email": "info@hailearning.edu.vn", + "location": "69 Street, 5th Avenue LA, United States", + "socialLinks": [ + { + "platform": "linkedin", + "url": "https://linkedin.com", + "icon": "fa-brands fa-linkedin" + }, + { + "platform": "twitter", + "url": "https://twitter.com", + "icon": "fa-brands fa-twitter" + }, + { + "platform": "instagram", + "url": "https://instagram.com", + "icon": "fa-brands fa-instagram" + }, + { + "platform": "youtube", + "url": "https://youtube.com", + "icon": "fa-brands fa-youtube" + } + ], + "languages": [ + { + "name": "English", + "value": "1" + }, + { + "name": "Bangla", + "value": "2" + }, + { + "name": "Hindi", + "value": "3" + } + ] }, - "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" + "offcanvas": { + "description": "Nullam dignissim, ante scelerisque the is euismod fermentum odio sem semper the is erat, a feugiat leo urna eget eros. Duis Aenean a imperdiet risus.", + "contactInfo": { + "address": "Main Street, Melbourne, Australia", + "email": "info@hailearning.edu.vn", + "workingHours": "Mod-Friday, 09am - 05pm", + "phone": "+09 378 357 5222" + } + } } - diff --git a/models/HeaderMenu.js b/models/HeaderMenu.js new file mode 100644 index 0000000..b5212a8 --- /dev/null +++ b/models/HeaderMenu.js @@ -0,0 +1,48 @@ +const mongoose = require('mongoose'); + +const HeaderMenuSchema = new mongoose.Schema({ + title: { + type: String, + required: true, + trim: true + }, + slug: { + type: String, + required: true, + trim: true, + lowercase: true + }, + url: { + type: String, + required: true, + trim: true + }, + parentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'HeaderMenu', + default: null + }, + order: { + type: Number, + default: 0 + }, + status: { + type: String, + enum: ['active', 'inactive'], + default: 'active' + }, + type: { + type: String, + enum: ['internal', 'external'], + default: 'internal' + } +}, { + timestamps: true +}); + +// Indexes for optimization +HeaderMenuSchema.index({ order: 1 }); +HeaderMenuSchema.index({ status: 1 }); +HeaderMenuSchema.index({ parentId: 1, order: 1 }); // Useful for fetching children in order + +module.exports = mongoose.model('HeaderMenu', HeaderMenuSchema); diff --git a/models/header.js b/models/header.js index 740a2c3..2c03f58 100644 --- a/models/header.js +++ b/models/header.js @@ -1,129 +1,115 @@ -const mongoose = require('mongoose'); +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 }); +const socialLinkSchema = new mongoose.Schema( + { + platform: { + type: String, + required: true, + enum: ["linkedin", "twitter", "instagram", "youtube", "facebook"], + }, + url: { + type: String, + required: true, + }, + icon: String, + order: { + type: Number, + default: 0, + }, + }, + { _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 }); +const languageSchema = new mongoose.Schema( + { + name: { + type: String, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + { _id: false }, +); -// Schema cho topbar -const topbarSchema = new mongoose.Schema({ - contactInfo: { - type: contactInfoSchema, - required: true - }, - links: { - type: [topbarLinkSchema], - default: [] - } -}, { _id: false }); +const menuItemSchema = new mongoose.Schema( + { + label: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + icon: String, + order: { + type: Number, + default: 0, + }, + children: [this], + }, + { _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 -}); +const headerSchema = new mongoose.Schema( + { + // Top bar + top: { + phone: String, + email: String, + location: String, + socialLinks: [socialLinkSchema], + languages: [languageSchema], + }, -// 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; - } -}; + // Offcanvas + offcanvas: { + description: String, + contactInfo: { + address: String, + email: String, + workingHours: String, + phone: String, + }, + }, -// 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; - } -}; + // Menu + menu: [menuItemSchema], -// 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; - } -}; + // Logo + logo: { + light: String, + dark: String, + alt: String, + }, -// 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; - } -}; + // CTA Button + ctaButton: { + label: String, + href: String, + style: { + type: String, + enum: ["primary", "secondary", "outline"], + default: "primary", + }, + }, -module.exports = mongoose.model('Header', headerSchema); \ No newline at end of file + // Status + status: { + type: String, + enum: ["active", "inactive"], + default: "active", + }, + + order: { + type: Number, + default: 1, + }, + }, + { timestamps: true }, +); + +module.exports = mongoose.model("Header", headerSchema); diff --git a/public/images/Logo_round.jpg b/public/images/Logo_round.jpg new file mode 100644 index 0000000..49125be Binary files /dev/null and b/public/images/Logo_round.jpg differ diff --git a/public/img/Logo_round.jpg b/public/img/Logo_round.jpg new file mode 100644 index 0000000..49125be Binary files /dev/null and b/public/img/Logo_round.jpg differ diff --git a/public/js/custom-modal.js b/public/js/custom-modal.js new file mode 100644 index 0000000..4875e4f --- /dev/null +++ b/public/js/custom-modal.js @@ -0,0 +1,31 @@ +/** + * Custom Modal Utilities + */ + +// Global function to clean up any stuck modal backdrops +window.forceCleanupModals = function() { + document.body.classList.remove('modal-open'); + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; + const backdrops = document.querySelectorAll('.modal-backdrop, .overlay, .loading'); + if (backdrops.length > 0) { + console.warn('Cleanup: Removing backdrops:', backdrops.length); + backdrops.forEach(el => el.remove()); + } +}; + +// Auto-cleanup on hide +document.addEventListener('hidden.bs.modal', () => { + setTimeout(forceCleanupModals, 100); +}); + +// Watchdog +setInterval(() => { + if (document.querySelectorAll('.modal.show').length === 0) { + if (document.querySelectorAll('.modal-backdrop').length > 0) { + forceCleanupModals(); + } + } +}, 2000); + +window.addEventListener('load', forceCleanupModals); diff --git a/public/js/flash-handler.js b/public/js/flash-handler.js new file mode 100644 index 0000000..40134da --- /dev/null +++ b/public/js/flash-handler.js @@ -0,0 +1,27 @@ +/** + * Flash Message Handler + * Displays server-side flash messages using showToast + */ + +document.addEventListener('DOMContentLoaded', () => { + const flashDataEl = document.getElementById('flash-messages-data'); + if (flashDataEl) { + try { + const messages = JSON.parse(flashDataEl.textContent); + + if (messages.success_msg) { + showToast('Success', messages.success_msg, 'success'); + } + + if (messages.error_msg) { + showToast('Error', messages.error_msg, 'error'); + } + + if (messages.error) { + showToast('Error', messages.error, 'error'); + } + } catch (e) { + console.error('Error parsing flash messages data:', e); + } + } +}); diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..9f66ccb --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,56 @@ +/** + * Global Admin Scripts + */ + +console.log('CMS Admin Main JS loaded'); + +// Helper to handle AJAX form submissions with confirmation or loading state +window.handleFormAjax = async (formEl, options = {}) => { + const { + confirmMessage = null, + onSuccess = null, + onError = null, + loaderBtn = null + } = options; + + if (confirmMessage && !confirm(confirmMessage)) { + return false; + } + + const formData = new FormData(formEl); + const data = Object.fromEntries(formData.entries()); + const originalBtnHtml = loaderBtn ? loaderBtn.innerHTML : null; + + try { + if (loaderBtn) { + loaderBtn.disabled = true; + loaderBtn.innerHTML = 'Processing...'; + } + + const response = await fetch(formEl.action, { + method: formEl.method || 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.success) { + if (onSuccess) onSuccess(result); + else showToast('Success', result.message || 'Operation successful', 'success'); + } else { + if (onError) onError(result); + else showToast('Error', result.message || 'Operation failed', 'error'); + } + } catch (error) { + console.error('AJAX Error:', error); + showToast('Error', 'A network error occurred', 'error'); + } finally { + if (loaderBtn) { + loaderBtn.disabled = false; + loaderBtn.innerHTML = originalBtnHtml; + } + } +}; diff --git a/public/js/toast.js b/public/js/toast.js new file mode 100644 index 0000000..e08ab91 --- /dev/null +++ b/public/js/toast.js @@ -0,0 +1,74 @@ +/** + * Toast Notification System + * Requires Bootstrap 5 JS + */ + +function showToast(title, message, type = 'info') { + // Create container if not exists + let toastContainer = document.getElementById('toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'toast-container'; + toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; + toastContainer.style.zIndex = '1100'; + document.body.appendChild(toastContainer); + } + + // Inject CSS for transition if not already present + if (!document.getElementById('toast-animation-styles')) { + const style = document.createElement('style'); + style.id = 'toast-animation-styles'; + style.innerHTML = ` + @keyframes slideInRight { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + .toast-slide-in { + animation: slideInRight 0.3s ease-out forwards; + } + `; + document.head.appendChild(style); + } + + // Map type to Bootstrap color class + const bgClass = type === 'success' ? 'bg-success' : (type === 'error' || type === 'danger' ? 'bg-danger' : 'bg-info'); + + // Create toast element + const toastEl = document.createElement('div'); + toastEl.className = `toast align-items-center text-white ${bgClass} border-0 mb-2 toast-slide-in`; + toastEl.setAttribute('role', 'alert'); + toastEl.setAttribute('aria-live', 'assertive'); + toastEl.setAttribute('aria-atomic', 'true'); + + toastEl.innerHTML = ` +
+
+
+ +
+ ${title}
+ ${message} +
+
+
+ +
+ `; + + toastContainer.appendChild(toastEl); + + // Initialize and show + const bsToast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 }); + bsToast.show(); + + // Remove from DOM when hidden + toastEl.addEventListener('hidden.bs.toast', () => { + toastEl.remove(); + }); +} + +// Map showNotification to showToast for backward compatibility +window.showNotification = function(message, type) { + const title = type === 'success' ? 'Success' : (type === 'error' ? 'Error' : 'Notification'); + showToast(title, message, type); +}; diff --git a/public/uploads/header/black-logo.svg b/public/uploads/header/black-logo.svg new file mode 100644 index 0000000..ec35168 --- /dev/null +++ b/public/uploads/header/black-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/uploads/header/white-logo.svg b/public/uploads/header/white-logo.svg new file mode 100644 index 0000000..486d3ee --- /dev/null +++ b/public/uploads/header/white-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/routes/admin.js b/routes/admin.js index d5233f5..38e0070 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -24,6 +24,7 @@ const insuranceController = require("../controllers/insuranceController"); const activityController = require("../controllers/activityController"); const bookingSubmissionController = require("../controllers/bookingSubmissionController"); const serviceController = require("../controllers/serviceController"); +const headerMenuController = require("../controllers/headerMenuController"); // Blog controllers const blogController = require("../controllers/blogController"); @@ -39,8 +40,8 @@ router.post("/home/update", ensureAuthenticated, homeController.update); // Middleware chuẩn hóa code router.param("code", (req, res, next, code) => { - req.params.code = code.toUpperCase(); - next(); + req.params.code = code.toUpperCase(); + next(); }); // About @@ -49,99 +50,49 @@ router.post("/about/update", ensureAuthenticated, aboutController.update); // AboutUs admin CRUD router.get("/about-us", ensureAuthenticated, aboutUsController.index); -router.get( - "/about-us/create", - ensureAuthenticated, - aboutUsController.createForm, -); +router.get("/about-us/create", ensureAuthenticated, aboutUsController.createForm); router.post("/about-us/create", ensureAuthenticated, aboutUsController.create); -router.get( - "/about-us/:id/edit", - ensureAuthenticated, - aboutUsController.editForm, -); -router.post( - "/about-us/:id/update", - ensureAuthenticated, - aboutUsController.update, -); -router.post( - "/about-us/:id/delete", - ensureAuthenticated, - aboutUsController.delete, -); -router.get( - "/about-us/:id/preview", - ensureAuthenticated, - aboutUsController.preview, -); +router.get("/about-us/:id/edit", ensureAuthenticated, aboutUsController.editForm); +router.post("/about-us/:id/update", ensureAuthenticated, aboutUsController.update); +router.post("/about-us/:id/delete", ensureAuthenticated, aboutUsController.delete); +router.get("/about-us/:id/preview", ensureAuthenticated, aboutUsController.preview); // Booking admin CRUD removed // Form Management router.get("/form", ensureAuthenticated, formController.index); -router.post( - "/form/update", - ensureAuthenticated, - formController.updateDefaultForm, -); +router.post("/form/update", ensureAuthenticated, formController.updateDefaultForm); // Upload routes router.get("/upload", ensureAuthenticated, (req, res) => { - res.render("admin/upload/index", { - layout: "layouts/admin", - title: "Quản lý Upload Ảnh", - user: req.session.user, - }); + res.render("admin/upload/index", { + layout: "layouts/admin", + title: "Quản lý Upload Ảnh", + user: req.session.user, + }); }); -router.post( - "/upload/image", - ensureAuthenticated, - upload.single("image"), - // convertToWebp, // Disabled to keep original image format (JPG/PNG) - uploadController.uploadImage, -); -router.post( - "/upload/video", - ensureAuthenticated, - uploadVideo.single("video"), - uploadController.uploadVideo, -); -router.post( - "/upload/update-path", - ensureAuthenticated, - uploadController.updateImagePath, -); -router.post( - "/upload/delete", - ensureAuthenticated, - uploadController.deleteImage, -); +router.post("/upload/image", ensureAuthenticated, upload.single("image"), uploadController.uploadImage); +router.post("/upload/video", ensureAuthenticated, uploadVideo.single("video"), uploadController.uploadVideo); +router.post("/upload/update-path", ensureAuthenticated, uploadController.updateImagePath); +router.post("/upload/delete", ensureAuthenticated, uploadController.deleteImage); +// Header routes // Header routes router.get("/header", ensureAuthenticated, headerController.index); router.post("/header/update", ensureAuthenticated, headerController.update); -router.post( - "/header/update-menu", - ensureAuthenticated, - headerController.updateMenu, -); -router.get( - "/header/menu-tree", - ensureAuthenticated, - headerController.getMenuTree, -); -router.get( - "/header/programmes/:menuId", - ensureAuthenticated, - headerController.getProgrammesByMenuId, -); -router.get( - "/header/menu-item/:menuId", - ensureAuthenticated, - headerController.getMenuItem, -); +router.post("/header/update-menu", ensureAuthenticated, headerController.updateMenu); +router.get("/header/menu-tree", ensureAuthenticated, headerController.getMenuTree); +router.get("/header/programmes/:menuId", ensureAuthenticated, headerController.getProgrammesByMenuId); +router.get("/header/menu-item/:menuId", ensureAuthenticated, headerController.getMenuItem); router.get("/header/data", ensureAuthenticated, headerController.getHeaderData); +router.patch("/header/:id/status", ensureAuthenticated, headerController.updateStatus); +router.delete("/header/:id", ensureAuthenticated, headerController.destroy); + +// Header Menu INTEGRATED routes +router.post("/header/menu/create", ensureAuthenticated, headerMenuController.createMenu); +router.post("/header/menu/update/:id", ensureAuthenticated, headerMenuController.updateMenu); +router.post("/header/menu/delete", ensureAuthenticated, headerMenuController.deleteMenu); +router.post("/header/menu/reorder", ensureAuthenticated, headerMenuController.reorderMenu); // Footer routes router.get("/footer", ensureAuthenticated, footerController.index); @@ -151,160 +102,60 @@ router.get("/footer/data", ensureAuthenticated, footerController.getFooterData); // Contact routes router.get("/contact", ensureAuthenticated, contactController.index); router.post("/contact/update", ensureAuthenticated, contactController.update); -router.get( - "/contact/data", - ensureAuthenticated, - contactController.getContactData, -); +router.get("/contact/data", ensureAuthenticated, contactController.getContactData); // Contact submissions management -router.get( - "/contact/submissions", - ensureAuthenticated, - contactController.getSubmissions, -); -router.put( - "/contact/submissions/:id", - ensureAuthenticated, - contactController.updateSubmissionStatus, -); +router.get("/contact/submissions", ensureAuthenticated, contactController.getSubmissions); +router.put("/contact/submissions/:id", ensureAuthenticated, contactController.updateSubmissionStatus); // Appointment management const appointmentController = require("../controllers/appointmentController"); -router.get( - "/appointments", - ensureAuthenticated, - appointmentController.getAppointments, -); -router.get( - "/appointments/:id", - ensureAuthenticated, - appointmentController.getAppointmentById, -); -router.put( - "/appointments/:id", - ensureAuthenticated, - appointmentController.updateAppointmentStatus, -); -router.delete( - "/appointments/:id", - ensureAuthenticated, - appointmentController.deleteAppointment, -); +router.get("/appointments", ensureAuthenticated, appointmentController.getAppointments); +router.get("/appointments/:id", ensureAuthenticated, appointmentController.getAppointmentById); +router.put("/appointments/:id", ensureAuthenticated, appointmentController.updateAppointmentStatus); +router.delete("/appointments/:id", ensureAuthenticated, appointmentController.deleteAppointment); // Appointment CMS page management router.get("/appointment", ensureAuthenticated, appointmentController.index); -router.post( - "/appointment/update", - ensureAuthenticated, - appointmentController.update, -); -router.get( - "/appointment/data", - ensureAuthenticated, - appointmentController.getAppointmentData, -); +router.post("/appointment/update", ensureAuthenticated, appointmentController.update); +router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData); // Pricing CMS page management const pricingController = require("../controllers/pricingController"); router.get("/pricing", ensureAuthenticated, pricingController.index); router.post("/pricing/update", ensureAuthenticated, pricingController.update); -router.get( - "/pricing/data", - ensureAuthenticated, - pricingController.getPricingData, -); +router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData); // Activity CRUD routes router.get("/activity", ensureAuthenticated, activityController.index); -router.get( - "/activity/create", - ensureAuthenticated, - activityController.createForm, -); +router.get("/activity/create", ensureAuthenticated, activityController.createForm); router.post("/activity/create", ensureAuthenticated, activityController.create); // Update filters (place before any parameterized /activity/:id routes to avoid route collision) -router.post( - "/activity/filters/update", - ensureAuthenticated, - activityController.updateFilters, -); +router.post("/activity/filters/update", ensureAuthenticated, activityController.updateFilters); // Update hero (global hero section for activities) -router.post( - "/activity/hero/update", - ensureAuthenticated, - activityController.updateHero, -); -router.get( - "/activity/:id/edit", - ensureAuthenticated, - activityController.editForm, -); -router.post( - "/activity/:id/update", - ensureAuthenticated, - activityController.update, -); -router.post( - "/activity/:id/delete", - ensureAuthenticated, - activityController.delete, -); -router.post( - "/activity/:id/toggle-status", - ensureAuthenticated, - activityController.toggleStatus, -); +router.post("/activity/hero/update", ensureAuthenticated, activityController.updateHero); +router.get("/activity/:id/edit", ensureAuthenticated, activityController.editForm); +router.post("/activity/:id/update", ensureAuthenticated, activityController.update); +router.post("/activity/:id/delete", ensureAuthenticated, activityController.delete); +router.post("/activity/:id/toggle-status", ensureAuthenticated, activityController.toggleStatus); // Update display order -router.post( - "/activity/update-order", - ensureAuthenticated, - activityController.updateOrder, -); +router.post("/activity/update-order", ensureAuthenticated, activityController.updateOrder); // Booking submissions routes -router.get( - "/activity/:id/bookings/count", - ensureAuthenticated, - activityController.getBookingCount, -); -router.get( - "/activity/:id/bookings", - ensureAuthenticated, - activityController.getBookingSubmissions, -); -router.get( - "/activity/:id/bookings/export", - ensureAuthenticated, - activityController.exportBookingData, -); +router.get("/activity/:id/bookings/count", ensureAuthenticated, activityController.getBookingCount); +router.get("/activity/:id/bookings", ensureAuthenticated, activityController.getBookingSubmissions); +router.get("/activity/:id/bookings/export", ensureAuthenticated, activityController.exportBookingData); // Export all bookings (across all activities) -router.get( - "/bookings/export-all", - ensureAuthenticated, - activityController.exportAllBookingsData, -); +router.get("/bookings/export-all", ensureAuthenticated, activityController.exportAllBookingsData); // Update booking submission -router.put( - "/bookings/:bookingId", - ensureAuthenticated, - bookingSubmissionController.updateBookingSubmission, -); +router.put("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.updateBookingSubmission); // Delete booking submission -router.delete( - "/bookings/:bookingId", - ensureAuthenticated, - bookingSubmissionController.deleteBookingSubmission, -); +router.delete("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.deleteBookingSubmission); // Update filters // Preview activity -router.get( - "/activity/:id/preview", - ensureAuthenticated, - activityController.preview, -); +router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview); // FAQ routes - Thêm vào đây router.get("/faq", ensureAuthenticated, faqController.index); @@ -413,65 +264,61 @@ router.post( // Test Image Paths route router.get("/test-images", ensureAuthenticated, (req, res) => { - const fs = require("fs"); - const path = require("path"); - const campLocationData = require("../data/camp-location.json"); + const fs = require("fs"); + const path = require("path"); + const campLocationData = require("../data/camp-location.json"); - // Collect all image paths - const imagePaths = []; + // Collect all image paths + const imagePaths = []; - // Camps images - if (campLocationData.camps) { - campLocationData.camps.forEach((camp) => { - if (camp.image) { - imagePaths.push({ - type: "Camp", - name: camp.title, - path: camp.image, - exists: fs.existsSync(path.join(__dirname, "../public", camp.image)), + // Camps images + if (campLocationData.camps) { + campLocationData.camps.forEach((camp) => { + if (camp.image) { + imagePaths.push({ + type: "Camp", + name: camp.title, + path: camp.image, + exists: fs.existsSync(path.join(__dirname, "../public", camp.image)), + }); + } }); - } + } + + // Locations images + if (campLocationData.locations) { + campLocationData.locations.forEach((location) => { + if (location.imageSrc) { + imagePaths.push({ + type: "Location", + name: location.title, + path: location.imageSrc, + exists: fs.existsSync(path.join(__dirname, "../public", location.imageSrc)), + }); + } + + // Program images + if (location.programOptions) { + location.programOptions.forEach((program) => { + if (program.imageSrc) { + imagePaths.push({ + type: "Program", + name: program.title, + path: program.imageSrc, + exists: fs.existsSync(path.join(__dirname, "../public", program.imageSrc)), + }); + } + }); + } + }); + } + + res.render("admin/test-images", { + layout: "layouts/admin", + title: "Test Image Paths", + images: imagePaths, + user: req.session.user, }); - } - - // Locations images - if (campLocationData.locations) { - campLocationData.locations.forEach((location) => { - if (location.imageSrc) { - imagePaths.push({ - type: "Location", - name: location.title, - path: location.imageSrc, - exists: fs.existsSync( - path.join(__dirname, "../public", location.imageSrc), - ), - }); - } - - // Program images - if (location.programOptions) { - location.programOptions.forEach((program) => { - if (program.imageSrc) { - imagePaths.push({ - type: "Program", - name: program.title, - path: program.imageSrc, - exists: fs.existsSync( - path.join(__dirname, "../public", program.imageSrc), - ), - }); - } - }); - } - }); - } - - res.render("admin/test-images", { - layout: "layouts/admin", - title: "Test Image Paths", - images: imagePaths, - user: req.session.user, - }); }); // Display visa management page diff --git a/routes/index.js b/routes/index.js index 9e941b9..101a396 100644 --- a/routes/index.js +++ b/routes/index.js @@ -5,10 +5,12 @@ const homeController = require("../controllers/homeController"); const aboutController = require("../controllers/aboutController"); const aboutUsController = require("../controllers/aboutUsController"); const headerController = require("../controllers/headerController"); +const socialLinkController = require("../controllers/socialLinkController"); const footerController = require("../controllers/footerController"); const contactController = require("../controllers/contactController"); const faqController = require("../controllers/faqController"); const visaController = require("../controllers/visaController"); +const headerMenuController = require("../controllers/headerMenuController"); const safetyController = require("../controllers/safetyController"); const campLocationController = require("../controllers/campLocationController"); // Booking flow removed @@ -27,10 +29,10 @@ const blogTagController = require("../controllers/blogTagController"); // Trang chủ router.get("/", (req, res) => { - res.render("index", { - title: "Welcome", - layout: "layouts/main", - }); + res.render("index", { + title: "Welcome", + layout: "layouts/main", + }); }); // API để lấy dữ liệu trang chủ @@ -50,6 +52,17 @@ router.get("/api/header", headerController.api); // Menu Tree API route (for frontend) router.get("/api/menu-tree", headerController.getMenuTreeAPI); +// Header Menu New Module API +router.get("/api/header-menu", headerMenuController.api); + +// Social Links API routes +router.get("/api/social-links", socialLinkController.getAll); +router.get("/api/social-links/:platform", socialLinkController.show); +router.post("/api/social-links", socialLinkController.store); +router.put("/api/social-links/:platform", socialLinkController.update); +router.delete("/api/social-links/:platform", socialLinkController.destroy); +router.post("/api/social-links/bulk-update", socialLinkController.bulkUpdate); + // Footer API route router.get("/api/footer", footerController.api); @@ -85,69 +98,57 @@ router.get("/api/terms", termsController.api); // Travel public page and API router.get("/travel", async (req, res) => { - try { - const Travel = require("../models/travel"); - const travel = await Travel.findOne(); + try { + const Travel = require("../models/travel"); + const travel = await Travel.findOne(); - if (!travel) { - return res.status(404).render("errors/404", { - title: "Page Not Found", - message: "Travel information not found", - }); + if (!travel) { + return res.status(404).render("errors/404", { + title: "Page Not Found", + message: "Travel information not found", + }); + } + + res.render("page/travel", { + title: travel.page.title, + data: travel.toObject(), + }); + } catch (error) { + console.error("Error loading travel page:", error); + res.status(500).render("errors/500", { + title: "Server Error", + message: "Error loading travel page", + }); } - - res.render("page/travel", { - title: travel.page.title, - data: travel.toObject(), - }); - } catch (error) { - console.error("Error loading travel page:", error); - res.status(500).render("errors/500", { - title: "Server Error", - message: "Error loading travel page", - }); - } }); router.get("/api/travel", travelController.api); // Booking submission APIs (public endpoints) router.post("/api/booking/submit", bookingSubmissionController.submitBooking); +router.get("/api/activity/:activityId/sessions", bookingSubmissionController.getAvailableSessions); router.get( - "/api/activity/:activityId/sessions", - bookingSubmissionController.getAvailableSessions, -); -router.get( - "/api/activity/:activityId/session/:sessionId/availability", - bookingSubmissionController.getSessionAvailability, + "/api/activity/:activityId/session/:sessionId/availability", + bookingSubmissionController.getSessionAvailability, ); // New API for creating bookings directly into camp sessions (by program) -router.post( - "/api/camps/:program/sessions/:sessionId/bookings", - activityController.createSessionBookingByProgram, -); -router.get( - "/api/camps/:program/sessions/:sessionId/bookings", - activityController.getSessionBookingsByProgram, -); +router.post("/api/camps/:program/sessions/:sessionId/bookings", activityController.createSessionBookingByProgram); +router.get("/api/camps/:program/sessions/:sessionId/bookings", activityController.getSessionBookingsByProgram); // Keep admin-style update/delete by activityId (protected) if needed -router.put( - "/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", - activityController.updateSessionBooking, -); +router.put("/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", activityController.updateSessionBooking); router.delete( - "/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", - activityController.deleteSessionBooking, + "/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", + activityController.deleteSessionBooking, ); // Demo booking form router.get("/demo/booking-form", (req, res) => { - res.sendFile(path.join(__dirname, "../views/demo/booking-form.html")); + res.sendFile(path.join(__dirname, "../views/demo/booking-form.html")); }); // Demo session booking API router.get("/demo/session-booking-api", (req, res) => { - res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html")); + res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html")); }); // Blog API Routes diff --git a/scripts/migrate-header-menu.js b/scripts/migrate-header-menu.js new file mode 100644 index 0000000..3f7cbba --- /dev/null +++ b/scripts/migrate-header-menu.js @@ -0,0 +1,69 @@ +const mongoose = require('mongoose'); +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); +const HeaderMenu = require('../models/HeaderMenu'); + +dotenv.config(); + +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/SIMS'; + +async function connectDB() { + try { + await mongoose.connect(MONGODB_URI); + console.log('✅ MongoDB Connected for Migration'); + } catch (err) { + console.error('❌ MongoDB Connection Error:', err); + process.exit(1); + } +} + +const processMenuItems = async (items, parentId = null) => { + for (const item of items) { + console.log(` > Importing: ${item.label}`); + + const menuDoc = { + title: item.label, + slug: item.slug, + url: item.href, + parentId: parentId, + order: item.order || 0, + status: item.isActive === false ? "inactive" : "active", + type: item.type === "external" ? "external" : "internal" + }; + + const createdItem = await HeaderMenu.create(menuDoc); + + if (item.children && item.children.length > 0) { + await processMenuItems(item.children, createdItem._id); + } + } +}; + +async function migrate() { + await connectDB(); + + try { + console.log('--- Starting Header Menu Migration ---'); + + // 1. Clear existing menu items + await HeaderMenu.deleteMany({}); + console.log('🗑️ Cleared existing HeaderMenu collection'); + + // 2. Read JSON data + const dataPath = path.join(__dirname, '../data/header-menu.json'); + const fileData = fs.readFileSync(dataPath, 'utf8'); + const menuItems = JSON.parse(fileData); + + // 3. Recursive import + await processMenuItems(menuItems); + + console.log('--- Migration Completed Successfully ---'); + process.exit(0); + } catch (error) { + console.error('❌ Migration Failed:', error); + process.exit(1); + } +} + +migrate(); diff --git a/scripts/migrate-header.js b/scripts/migrate-header.js new file mode 100644 index 0000000..05b3dc7 --- /dev/null +++ b/scripts/migrate-header.js @@ -0,0 +1,73 @@ +const mongoose = require("mongoose"); +const path = require("path"); +require("dotenv").config({ path: path.join(__dirname, "../.env") }); + +const Header = require("../models/header"); +const headerData = require("../data/header.json"); + +const migrateHeader = async () => { + try { + const mongoUri = process.env.MONGODB_URI; + if (!mongoUri) { + throw new Error("MONGODB_URI not found in environment variables"); + } + await mongoose.connect(mongoUri); + console.log("Connected to MongoDB"); + + // Delete existing header + await Header.deleteMany({}); + console.log("Cleared existing headers"); + + // Transform and insert data + const headerDocument = { + top: { + phone: headerData.top.phone, + email: headerData.top.email, + location: headerData.top.location, + socialLinks: headerData.top.socialLinks.map((link, idx) => ({ + ...link, + order: idx, + })), + languages: headerData.top.languages, + }, + offcanvas: headerData.offcanvas, + menu: headerData.menu.map((item, idx) => ({ + ...item, + order: idx, + children: + item.children?.map((child, childIdx) => ({ + ...child, + order: childIdx, + children: + child.children?.map((subchild, subIdx) => ({ + ...subchild, + order: subIdx, + })) || [], + })) || [], + })), + logo: { + light: "/assets/img/logo/white-logo.svg", + dark: "/assets/img/logo/black-logo.svg", + alt: "Hai Learning", + }, + ctaButton: { + label: "Get Started", + href: "/contact", + style: "primary", + }, + status: "active", + order: 1, + }; + + const result = await Header.create(headerDocument); + console.log("Header migrated successfully:", result._id); + + await mongoose.connection.close(); + process.exit(0); + } catch (error) { + console.error("Migration error:", error); + process.exit(1); + } +}; + +migrateHeader(); diff --git a/scripts/update-header-data.js b/scripts/update-header-data.js new file mode 100644 index 0000000..0607352 --- /dev/null +++ b/scripts/update-header-data.js @@ -0,0 +1,106 @@ +const mongoose = require("mongoose"); +const path = require("path"); +require("dotenv").config({ path: path.join(__dirname, "../.env") }); + +const Header = require("../models/header"); + +async function updateHeaderData() { + try { + // Connect to MongoDB + await mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning"); + console.log("Connected to MongoDB"); + + // Find the first header + let header = await Header.findOne().sort({ order: 1 }); + + if (!header) { + console.log("No header found, creating new one..."); + header = new Header({ + top: { + phone: "+09 378 357 5222", + email: "info@hailearning.edu.vn", + location: "69 Street, 5th Avenue LA, United States", + socialLinks: [ + { + platform: "linkedin", + url: "https://linkedin.com", + icon: "fa-brands fa-linkedin", + }, + { + platform: "twitter", + url: "https://twitter.com", + icon: "fa-brands fa-twitter", + }, + { + platform: "instagram", + url: "https://instagram.com", + icon: "fa-brands fa-instagram", + }, + { + platform: "youtube", + url: "https://youtube.com", + icon: "fa-brands fa-youtube", + }, + ], + languages: [ + { name: "English", value: "1" }, + { name: "Bangla", value: "2" }, + { name: "Hindi", value: "3" }, + ], + }, + status: "active", + order: 1, + }); + } else { + console.log("Header found, updating..."); + // Update existing header + header.top = { + phone: header.top?.phone || "+09 378 357 5222", + email: header.top?.email || "info@hailearning.edu.vn", + location: header.top?.location || "69 Street, 5th Avenue LA, United States", + socialLinks: + header.top?.socialLinks?.length > 0 + ? header.top.socialLinks + : [ + { + platform: "linkedin", + url: "https://linkedin.com", + icon: "fa-brands fa-linkedin", + }, + { + platform: "twitter", + url: "https://twitter.com", + icon: "fa-brands fa-twitter", + }, + { + platform: "instagram", + url: "https://instagram.com", + icon: "fa-brands fa-instagram", + }, + { + platform: "youtube", + url: "https://youtube.com", + icon: "fa-brands fa-youtube", + }, + ], + languages: header.top?.languages || [ + { name: "English", value: "1" }, + { name: "Bangla", value: "2" }, + { name: "Hindi", value: "3" }, + ], + }; + } + + await header.save(); + console.log("Header updated successfully!"); + console.log("Header data:", JSON.stringify(header, null, 2)); + + await mongoose.connection.close(); + console.log("Database connection closed"); + } catch (error) { + console.error("Error updating header:", error); + process.exit(1); + } +} + +updateHeaderData(); diff --git a/server.js b/server.js index 924d8f0..721fac3 100644 --- a/server.js +++ b/server.js @@ -34,24 +34,29 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use( - "/assets", - (req, res, next) => { - // Cho phép mọi domain truy cập tài nguyên tĩnh - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET"); - next(); - }, - express.static(path.join(__dirname, "assets")), + "/assets", + (req, res, next) => { + // Cho phép mọi domain truy cập tài nguyên tĩnh + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Methods", "GET"); + next(); + }, + express.static(path.join(__dirname, "assets")), ); + +// Serve public folder at root (for /js, /img, etc.) +app.use(express.static(path.join(__dirname, "public"))); + +// Serve uploads folder app.use( - "/uploads", - (req, res, next) => { - // Cho phép mọi domain truy cập tài nguyên tĩnh - res.header("Access-Control-Allow-Origin", "*"); - res.header("Access-Control-Allow-Methods", "GET"); - next(); - }, - express.static(path.join(__dirname, "public", "uploads")), + "/uploads", + (req, res, next) => { + // Cho phép mọi domain truy cập tài nguyên tĩnh + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Methods", "GET"); + next(); + }, + express.static(path.join(__dirname, "public", "uploads")), ); // Serve other public files @@ -59,21 +64,21 @@ app.use(express.static(path.join(__dirname, "public"))); // Session configuration (using MongoDB store to avoid logout khi server restart) app.use( - session({ - secret: process.env.SESSION_SECRET || "secret", - resave: false, - saveUninitialized: false, - store: MongoStore.create({ - mongoUrl: process.env.MONGODB_URI, - collectionName: "sessions", - ttl: 60 * 60 * 24, // 24 hours (in seconds) + session({ + secret: process.env.SESSION_SECRET || "secret", + resave: false, + saveUninitialized: false, + store: MongoStore.create({ + mongoUrl: process.env.MONGODB_URI, + collectionName: "sessions", + ttl: 60 * 60 * 24, // 24 hours (in seconds) + }), + cookie: { + maxAge: 1000 * 60 * 60 * 24, // 24 hours + httpOnly: true, + sameSite: "lax", + }, }), - cookie: { - maxAge: 1000 * 60 * 60 * 24, // 24 hours - httpOnly: true, - sameSite: "lax", - }, - }), ); // Flash messages @@ -81,32 +86,32 @@ app.use(flash()); // Global variables app.use((req, res, next) => { - // Lấy flash messages - const success_msg = req.flash("success_msg"); - const error_msg = req.flash("error_msg"); - const error = req.flash("error"); + // Lấy flash messages + const success_msg = req.flash("success_msg"); + const error_msg = req.flash("error_msg"); + const error = req.flash("error"); - // Lưu vào res.locals để sử dụng trong views - res.locals.success_msg = success_msg.length > 0 ? success_msg[0] : null; - res.locals.error_msg = error_msg.length > 0 ? error_msg[0] : null; - res.locals.error = error.length > 0 ? error[0] : null; + // Lưu vào res.locals để sử dụng trong views + res.locals.success_msg = success_msg.length > 0 ? success_msg[0] : null; + res.locals.error_msg = error_msg.length > 0 ? error_msg[0] : null; + res.locals.error = error.length > 0 ? error[0] : null; - // Tạo object flashMessages để sử dụng trong client-side JavaScript - res.locals.flashMessagesJSON = JSON.stringify({ - success_msg: res.locals.success_msg, - error_msg: res.locals.error_msg, - error: res.locals.error, - }); + // Tạo object flashMessages để sử dụng trong client-side JavaScript + res.locals.flashMessagesJSON = JSON.stringify({ + success_msg: res.locals.success_msg, + error_msg: res.locals.error_msg, + error: res.locals.error, + }); - res.locals.user = req.session.user || null; - res.locals.currentPath = req.path; - next(); + res.locals.user = req.session.user || null; + res.locals.currentPath = req.path; + next(); }); // Kiểm tra và tạo thư mục data nếu chưa tồn tại const dataDir = path.join(__dirname, "data"); if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir); + fs.mkdirSync(dataDir); } // Frontend URL configuration @@ -114,39 +119,30 @@ const FRONTEND_URL = process.env.FRONTEND_URL; // Add frontend URL to res.locals for all requests app.use((req, res, next) => { - res.locals.frontendUrl = FRONTEND_URL; - res.locals.currentPath = req.path; - next(); + res.locals.frontendUrl = FRONTEND_URL; + res.locals.currentPath = req.path; + next(); }); // Simple CORS middleware for API endpoints app.use((req, res, next) => { - // Allow requests from configured FRONTEND_URL or allow all if not set - const origin = req.headers.origin; - const allowedOrigin = FRONTEND_URL || "*"; + // Allow requests from configured FRONTEND_URL or allow all if not set + const origin = req.headers.origin; + const allowedOrigin = FRONTEND_URL || "*"; - if (allowedOrigin === "*" || origin === allowedOrigin) { - res.setHeader( - "Access-Control-Allow-Origin", - allowedOrigin === "*" ? "*" : origin, - ); - res.setHeader( - "Access-Control-Allow-Methods", - "GET,POST,PUT,DELETE,OPTIONS", - ); - res.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization", - ); - res.setHeader("Access-Control-Allow-Credentials", "true"); - } + if (allowedOrigin === "*" || origin === allowedOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowedOrigin === "*" ? "*" : origin); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + } - // Handle preflight - if (req.method === "OPTIONS") { - return res.sendStatus(204); - } + // Handle preflight + if (req.method === "OPTIONS") { + return res.sendStatus(204); + } - next(); + next(); }); // Routes @@ -160,19 +156,19 @@ app.use("/", indexRoutes); // 404 handler app.use((req, res) => { - res.status(404); - if (req.accepts("html")) - return res.render("page/404", { - title: "404 - Page Not Found", - layout: "layouts/main", - }); - if (req.accepts("json")) return res.json({ error: "Not found" }); - res.type("txt").send("Not found"); + res.status(404); + if (req.accepts("html")) + return res.render("page/404", { + title: "404 - Page Not Found", + layout: "layouts/main", + }); + if (req.accepts("json")) return res.json({ error: "Not found" }); + res.type("txt").send("Not found"); }); // Start server const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || "localhost"; app.listen(PORT, HOST, () => { - console.log(`🚀 SERVER:[ http://${HOST}:${PORT} ]`); + console.log(`🚀 SERVER:[ http://${HOST}:${PORT} ]`); }); diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs index a72f79b..b7ee5a5 100644 --- a/views/admin/dashboard.ejs +++ b/views/admin/dashboard.ejs @@ -8,6 +8,7 @@
+
@@ -26,6 +27,7 @@
+
@@ -44,6 +46,7 @@
+
@@ -62,6 +65,7 @@
+
@@ -80,6 +84,7 @@
+
@@ -98,6 +103,7 @@
+
@@ -116,6 +122,7 @@
+
@@ -134,6 +141,7 @@
+
@@ -152,7 +160,8 @@
-
+ +
Manage terms

- + Edit
+
@@ -191,7 +198,8 @@
-
+ +
-
+ +
Manage camp location

- + Edit
-
+ + diff --git a/views/admin/header/index.ejs b/views/admin/header/index.ejs index 6ca7979..8fe3439 100644 --- a/views/admin/header/index.ejs +++ b/views/admin/header/index.ejs @@ -1,36 +1,53 @@
-

Header Management

+

+ Header Management +

Edit header content and menu structure

-
+
- - - - + + + +