forked from UKSOURCE/cms.hailearning.edu.vn
Merge branch 'main' of https://gits.techvanguard.vn/UKSOURCE/cms.hailearning.edu.vn into fea/thanh-05022026-home
This commit is contained in:
@@ -1,178 +1,143 @@
|
||||
const { addBaseUrlToImages } = require("../utils/imageHelper");
|
||||
const Footer = require("../models/footer");
|
||||
|
||||
// Get footer data from MongoDB
|
||||
const getFooterData = async () => {
|
||||
const footer = await Footer.findOne({ name: "default" });
|
||||
// GET /api/footer - Public API cho website và CMS load dữ liệu
|
||||
exports.getFooter = async (req, res) => {
|
||||
try {
|
||||
const footer = await Footer.getSingle();
|
||||
const processedData = addBaseUrlToImages(footer.toObject());
|
||||
|
||||
if (!footer) {
|
||||
return {
|
||||
logo: {
|
||||
src: '',
|
||||
alt: ''
|
||||
},
|
||||
about: {
|
||||
title: "About GGC",
|
||||
description: "",
|
||||
mapLink: {
|
||||
text: "Check on google map",
|
||||
url: "",
|
||||
},
|
||||
},
|
||||
address: {
|
||||
text: "",
|
||||
address2: "",
|
||||
mapUrl: "",
|
||||
},
|
||||
contact: {
|
||||
phone: "",
|
||||
hours: "",
|
||||
email: "",
|
||||
},
|
||||
columns: [],
|
||||
social: {
|
||||
links: [],
|
||||
},
|
||||
copyright: {
|
||||
text: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return footer.toObject();
|
||||
res.json(processedData);
|
||||
} catch (error) {
|
||||
console.error("Error getting footer:", error);
|
||||
res.status(500).json({
|
||||
error: "Failed to get footer data",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API to get footer data
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
// Lấy footer data
|
||||
const footer = await getFooterData();
|
||||
// PUT /api/admin/footer - Update toàn bộ footer cho CMS
|
||||
exports.updateFooter = async (req, res) => {
|
||||
try {
|
||||
let updateData = req.body;
|
||||
|
||||
// Xử lý URL cho logo và các hình ảnh khác
|
||||
const processedData = addBaseUrlToImages(footer);
|
||||
console.log("=== FOOTER UPDATE REQUEST RECEIVED ===");
|
||||
console.log("Raw body:", JSON.stringify(req.body, null, 2));
|
||||
|
||||
res.json(processedData);
|
||||
} catch (err) {
|
||||
console.error("API Error:", err);
|
||||
res.status(500).json({ error: "Error loading footer data" });
|
||||
}
|
||||
// Nếu có footerJson, parse nó (tương tự Header logic)
|
||||
if (updateData.footerJson && typeof updateData.footerJson === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(updateData.footerJson);
|
||||
console.log("✓ Parsed footerJson successfully:", parsedData);
|
||||
updateData = parsedData;
|
||||
} catch (e) {
|
||||
console.error("✗ Error parsing footerJson:", e.message);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Invalid JSON in footerJson: " + e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lấy footer hiện tại hoặc tạo mới (giống Header logic)
|
||||
let footer = await Footer.findOne();
|
||||
|
||||
if (!footer) {
|
||||
console.log("No existing footer found, creating new one");
|
||||
footer = new Footer(updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer created:", footer._id);
|
||||
} else {
|
||||
console.log("✓ Found existing footer:", footer._id);
|
||||
// Merge với dữ liệu cũ thay vì overwrite (giống Header)
|
||||
Object.assign(footer, updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer updated successfully");
|
||||
}
|
||||
|
||||
const processedData = addBaseUrlToImages(footer.toObject());
|
||||
|
||||
console.log("Updated footer data:", JSON.stringify(processedData, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Footer updated successfully",
|
||||
data: processedData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("✗ Error updating footer:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to update footer: " + error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// API để lấy toàn bộ footer data
|
||||
exports.getFooterData = async (req, res) => {
|
||||
try {
|
||||
const footerData = await getFooterData();
|
||||
const processed = addBaseUrlToImages(footerData);
|
||||
res.json(processed);
|
||||
} catch (error) {
|
||||
console.error("Error getting footer data:", error);
|
||||
res.status(500).json({ error: "Error loading footer data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Render admin view
|
||||
// Render admin view (giữ lại cho UI hiện tại)
|
||||
exports.index = async (req, res) => {
|
||||
try {
|
||||
const data = await getFooterData();
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
try {
|
||||
const data = await Footer.getSingle();
|
||||
const processedData = addBaseUrlToImages(data.toObject());
|
||||
|
||||
// Ensure image paths are absolute for admin preview
|
||||
const processedData = addBaseUrlToImages(data);
|
||||
|
||||
res.render("admin/footer/index", {
|
||||
|
||||
title: "Footer Management",
|
||||
data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in footer index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
res.render("admin/footer/index", {
|
||||
title: "Footer Management",
|
||||
data: processedData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in footer index:", error);
|
||||
req.flash("error_msg", "An error occurred while loading the page");
|
||||
res.redirect("/admin/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật dữ liệu footer
|
||||
// Update method cho form hiện tại (giống Header pattern)
|
||||
exports.update = async (req, res) => {
|
||||
try {
|
||||
let footerData = req.body;
|
||||
if (footerData.footerJson) {
|
||||
try {
|
||||
footerData = JSON.parse(footerData.footerJson);
|
||||
} catch (err) {
|
||||
console.warn('Invalid footerJson payload, falling back to req.body');
|
||||
}
|
||||
try {
|
||||
let updateData = req.body;
|
||||
|
||||
console.log("=== FOOTER FORM UPDATE REQUEST RECEIVED ===");
|
||||
console.log("Raw body:", JSON.stringify(req.body, null, 2));
|
||||
|
||||
// Nếu có footerJson, parse nó (giống Header logic)
|
||||
if (updateData.footerJson && typeof updateData.footerJson === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(updateData.footerJson);
|
||||
console.log("✓ Parsed footerJson successfully:", parsedData);
|
||||
updateData = parsedData;
|
||||
} catch (e) {
|
||||
console.error("✗ Error parsing footerJson:", e.message);
|
||||
req.flash("error_msg", "Invalid JSON in footerJson: " + e.message);
|
||||
return res.redirect("/admin/footer");
|
||||
}
|
||||
}
|
||||
|
||||
// Lấy footer hiện tại hoặc tạo mới (giống Header)
|
||||
let footer = await Footer.findOne();
|
||||
|
||||
if (!footer) {
|
||||
console.log("No existing footer found, creating new one");
|
||||
footer = new Footer(updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer created:", footer._id);
|
||||
req.flash("success_msg", "Footer created successfully");
|
||||
} else {
|
||||
console.log("✓ Found existing footer:", footer._id);
|
||||
// Merge với dữ liệu cũ (giống Header)
|
||||
Object.assign(footer, updateData);
|
||||
await footer.save();
|
||||
console.log("✓ Footer updated successfully");
|
||||
req.flash("success_msg", "Footer updated successfully");
|
||||
}
|
||||
|
||||
const activeTab = req.body.activeTab || "about";
|
||||
res.redirect(`/admin/footer?activeTab=${activeTab}`);
|
||||
} catch (err) {
|
||||
console.error("✗ Error updating footer:", err);
|
||||
req.flash("error_msg", err.message || "Error updating footer");
|
||||
res.redirect("/admin/footer");
|
||||
}
|
||||
|
||||
// Tìm footer hiện có hoặc tạo mới nếu không tồn tại
|
||||
let footer = await Footer.findOne({ name: "default" });
|
||||
|
||||
if (!footer) {
|
||||
footer = new Footer({
|
||||
name: "default",
|
||||
about: footerData.about || {
|
||||
title: "About GGC",
|
||||
description: "",
|
||||
mapLink: { text: "Check on google map", url: "" },
|
||||
},
|
||||
address: footerData.address || {
|
||||
text: "",
|
||||
address2: "",
|
||||
mapUrl: "",
|
||||
link2: "",
|
||||
},
|
||||
contact: footerData.contact || { phone: "", hours: "", email: "" },
|
||||
columns: Array.isArray(footerData.columns) ? footerData.columns : [],
|
||||
social: footerData.social || { links: [] },
|
||||
copyright: footerData.copyright || { text: "" },
|
||||
});
|
||||
} else {
|
||||
// Cập nhật các trường
|
||||
if (footerData.about) {
|
||||
footer.about = {
|
||||
title: footerData.about.title || footer.about?.title || "About GGC",
|
||||
description: footerData.about.description || footer.about?.description || "",
|
||||
mapLink: {
|
||||
text: footerData.about.mapLink?.text || footer.about?.mapLink?.text || "Check on google map",
|
||||
url: footerData.about.mapLink?.url || footer.about?.mapLink?.url || ""
|
||||
}
|
||||
};
|
||||
}
|
||||
if (footerData.address) {
|
||||
// Đảm bảo address2 tồn tại để tránh undefined trong view/schema
|
||||
footer.address = {
|
||||
...(footer.address?.toObject
|
||||
? footer.address.toObject()
|
||||
: footer.address),
|
||||
...footerData.address,
|
||||
address2:
|
||||
footerData.address.address2 !== undefined
|
||||
? footerData.address.address2
|
||||
: footer.address?.address2 || "",
|
||||
link2:
|
||||
footerData.address.link2 !== undefined
|
||||
? footerData.address.link2
|
||||
: footer.address?.link2 || "",
|
||||
};
|
||||
}
|
||||
if (footerData.contact) footer.contact = footerData.contact;
|
||||
if (Array.isArray(footerData.columns))
|
||||
footer.columns = footerData.columns;
|
||||
if (footerData.social && Array.isArray(footerData.social.links))
|
||||
footer.social = footerData.social;
|
||||
if (footerData.copyright && typeof footerData.copyright.text === "string")
|
||||
footer.copyright = footerData.copyright;
|
||||
}
|
||||
|
||||
await footer.save();
|
||||
|
||||
req.flash("success_msg", "Footer updated successfully");
|
||||
|
||||
// Redirect back to the active tab
|
||||
const activeTab = req.body.activeTab || 'about';
|
||||
res.redirect(`/admin/footer?activeTab=${activeTab}`);
|
||||
} catch (err) {
|
||||
console.error("Error updating footer:", err);
|
||||
req.flash("error_msg", err.message || "Error updating footer");
|
||||
res.redirect("/admin/footer");
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy API endpoints (giữ lại cho tương thích)
|
||||
exports.api = exports.getFooter;
|
||||
exports.getFooterData = exports.getFooter;
|
||||
|
||||
154
data/footer.json
154
data/footer.json
@@ -1,80 +1,80 @@
|
||||
{
|
||||
"about": {
|
||||
"title": "About GGC",
|
||||
"description": "Welcome to Go and Grow Camp, where adventure, learning, and friendships come together. Join us for unforgettable experiences that inspire confidence, creativity, and connection",
|
||||
"mapLink": {
|
||||
"text": "Check on google map",
|
||||
"url": "https://share.google/8G4LFRq82OwOmlFlN"
|
||||
"top": {
|
||||
"bgImage": "/assets/img/home-1/footer-bg.jpg",
|
||||
"phone": {
|
||||
"display": "+84 961 83 4040",
|
||||
"href": "tel:+84961834040"
|
||||
},
|
||||
"address": "734 Luy Ban Bich St, Tan Thanh Ward, Tan Phu Dist, HCMC",
|
||||
"logo": {
|
||||
"src": "/assets/img/logo/white-logo.svg",
|
||||
"alt": "logo",
|
||||
"href": "/"
|
||||
},
|
||||
"menuLinks": [
|
||||
{
|
||||
"label": "Home",
|
||||
"href": "/"
|
||||
},
|
||||
{
|
||||
"label": "About Us",
|
||||
"href": "/about"
|
||||
},
|
||||
{
|
||||
"label": "Visa",
|
||||
"href": "/country-details"
|
||||
},
|
||||
{
|
||||
"label": "Pages",
|
||||
"href": "/news-details"
|
||||
},
|
||||
{
|
||||
"label": "Article",
|
||||
"href": "/news"
|
||||
},
|
||||
{
|
||||
"label": "Contact Us",
|
||||
"href": "/contact"
|
||||
}
|
||||
],
|
||||
"socialLinks": [
|
||||
{
|
||||
"icon": "fa-brands fa-twitter",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"icon": "fa-brands fa-instagram",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"icon": "fa-brands fa-linkedin",
|
||||
"href": "#"
|
||||
},
|
||||
{
|
||||
"icon": "fa-brands fa-youtube",
|
||||
"href": "#"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bottom": {
|
||||
"copyright": {
|
||||
"text": "Copyright©",
|
||||
"brand": "GRAMENTHEME",
|
||||
"rights": "All Rights Reserved."
|
||||
},
|
||||
"menuLinks": [
|
||||
{
|
||||
"label": "Terms & Conditions",
|
||||
"href": "/contact"
|
||||
},
|
||||
{
|
||||
"label": "Privacy Policy",
|
||||
"href": "/contact"
|
||||
},
|
||||
{
|
||||
"label": "Contact Us",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"address": {
|
||||
"text": "Poblacion, Madridejos 22, Cebu City, Philippines",
|
||||
"address2": "",
|
||||
"link2": "",
|
||||
"mapUrl": "https://share.google/8G4LFRq82OwOmlFlN"
|
||||
},
|
||||
"contact": {
|
||||
"phone": "+123456789",
|
||||
"hours": "Mon - Fri 8.00-18.00",
|
||||
"email": "office@ggcamp.org"
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
"title": "Explore",
|
||||
"links": [
|
||||
{
|
||||
"title": "Home",
|
||||
"url": "/"
|
||||
},
|
||||
{
|
||||
"title": "Activities",
|
||||
"url": "/destinations"
|
||||
},
|
||||
{
|
||||
"title": "Camp Locations",
|
||||
"url": "/camp-profiles"
|
||||
},
|
||||
{
|
||||
"title": "About",
|
||||
"url": "/info/about"
|
||||
},
|
||||
{
|
||||
"title": "Contact",
|
||||
"url": "/info/contact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"social": {
|
||||
"links": [
|
||||
{
|
||||
"platform": "Facebook",
|
||||
"url": "https://www.facebook.com/campadventuregermany/",
|
||||
"icon": "facebook"
|
||||
},
|
||||
{
|
||||
"platform": "Twitter",
|
||||
"url": "#",
|
||||
"icon": "twitter"
|
||||
},
|
||||
{
|
||||
"platform": "Instagram",
|
||||
"url": "https://www.instagram.com/campadventuregermany/",
|
||||
"icon": "instagram"
|
||||
},
|
||||
{
|
||||
"platform": "Behance",
|
||||
"url": "#",
|
||||
"icon": "behance"
|
||||
},
|
||||
{
|
||||
"platform": "LinkedIn",
|
||||
"url": "#",
|
||||
"icon": "linkedin"
|
||||
}
|
||||
]
|
||||
},
|
||||
"copyright": {
|
||||
"text": "© 2025 GGC @ All Rights Reserved"
|
||||
}
|
||||
}
|
||||
|
||||
383
models/footer.js
383
models/footer.js
@@ -1,234 +1,213 @@
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
// Schema cho menu links
|
||||
const menuLinkSchema = new mongoose.Schema(
|
||||
{
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho social links
|
||||
const socialLinkSchema = new mongoose.Schema(
|
||||
{
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
{
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho footer links
|
||||
const footerLinkSchema = new mongoose.Schema(
|
||||
{
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
// Schema cho phone
|
||||
const phoneSchema = new mongoose.Schema(
|
||||
{
|
||||
display: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Schema cho footer columns
|
||||
const footerColumnSchema = new mongoose.Schema(
|
||||
{
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
links: {
|
||||
type: [footerLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho logo
|
||||
const logoSchema = new mongoose.Schema(
|
||||
{
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
{
|
||||
src: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "/",
|
||||
},
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Schema cho address
|
||||
const addressSchema = new mongoose.Schema(
|
||||
{
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
// Thêm address2 (địa chỉ dòng 2) không bắt buộc
|
||||
address2: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
// Optional secondary link (e.g., a second map or external resource)
|
||||
link2: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
mapUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Schema cho contact info
|
||||
const contactInfoSchema = new mongoose.Schema(
|
||||
{
|
||||
phone: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
hours: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho copyright
|
||||
const copyrightSchema = new mongoose.Schema(
|
||||
{
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
{
|
||||
text: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "Copyright©",
|
||||
},
|
||||
brand: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
rights: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "All Rights Reserved.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Schema cho about section (column đầu tiên)
|
||||
const aboutSchema = new mongoose.Schema(
|
||||
{
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
// Schema cho top section
|
||||
const topSchema = new mongoose.Schema(
|
||||
{
|
||||
bgImage: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
phone: {
|
||||
type: phoneSchema,
|
||||
default: () => ({ display: "", href: "" }),
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
required: false,
|
||||
trim: true,
|
||||
default: "",
|
||||
},
|
||||
logo: {
|
||||
type: logoSchema,
|
||||
default: () => ({ src: "", alt: "", href: "/" }),
|
||||
},
|
||||
menuLinks: {
|
||||
type: [menuLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
socialLinks: {
|
||||
type: [socialLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
mapLink: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Main Footer Schema
|
||||
// Schema cho bottom section
|
||||
const bottomSchema = new mongoose.Schema(
|
||||
{
|
||||
copyright: {
|
||||
type: copyrightSchema,
|
||||
default: () => ({ text: "Copyright©", brand: "", rights: "All Rights Reserved." }),
|
||||
},
|
||||
menuLinks: {
|
||||
type: [menuLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Main Footer Schema - khớp 100% với footer.json
|
||||
const footerSchema = new mongoose.Schema(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
default: "default",
|
||||
unique: true,
|
||||
{
|
||||
top: {
|
||||
type: topSchema,
|
||||
default: () => ({
|
||||
bgImage: "",
|
||||
phone: { display: "", href: "" },
|
||||
address: "",
|
||||
logo: { src: "", alt: "", href: "/" },
|
||||
menuLinks: [],
|
||||
socialLinks: [],
|
||||
}),
|
||||
},
|
||||
bottom: {
|
||||
type: bottomSchema,
|
||||
default: () => ({
|
||||
copyright: { text: "Copyright©", brand: "", rights: "All Rights Reserved." },
|
||||
menuLinks: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
about: {
|
||||
type: aboutSchema,
|
||||
required: true,
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
address: {
|
||||
type: addressSchema,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: contactInfoSchema,
|
||||
required: true,
|
||||
},
|
||||
columns: {
|
||||
type: [footerColumnSchema],
|
||||
default: [],
|
||||
},
|
||||
social: {
|
||||
links: {
|
||||
type: [socialLinkSchema],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
copyright: {
|
||||
type: copyrightSchema,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Tạo migration script để import dữ liệu từ footer.json
|
||||
footerSchema.statics.migrateFromJson = async function (jsonData) {
|
||||
try {
|
||||
// Kiểm tra xem đã có footer mặc định chưa
|
||||
const existingFooter = await this.findOne({ name: "default" });
|
||||
|
||||
if (existingFooter) {
|
||||
// Cập nhật footer hiện có
|
||||
Object.assign(existingFooter, jsonData);
|
||||
await existingFooter.save();
|
||||
console.log("Footer data updated successfully");
|
||||
return existingFooter;
|
||||
} else {
|
||||
// Tạo footer mới với dữ liệu từ JSON
|
||||
const newFooter = await this.create({
|
||||
name: "default",
|
||||
...jsonData,
|
||||
});
|
||||
console.log("Footer data imported successfully");
|
||||
return newFooter;
|
||||
// Static method để lấy hoặc tạo footer duy nhất
|
||||
footerSchema.statics.getSingle = async function () {
|
||||
let footer = await this.findOne();
|
||||
if (!footer) {
|
||||
footer = await this.create({});
|
||||
}
|
||||
return footer;
|
||||
};
|
||||
|
||||
// Migration method để import từ JSON hiện tại
|
||||
footerSchema.statics.migrateFromJson = async function (jsonData) {
|
||||
try {
|
||||
// Xóa tất cả documents hiện có
|
||||
await this.deleteMany({});
|
||||
|
||||
// Tạo document mới
|
||||
const footer = await this.create(jsonData);
|
||||
console.log("Footer data migrated successfully");
|
||||
return footer;
|
||||
} catch (error) {
|
||||
console.error("Error migrating footer data:", error);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error migrating footer data:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model("Footer", footerSchema);
|
||||
|
||||
286
models/visa.js
286
models/visa.js
@@ -6,221 +6,221 @@ const mongoose = require("mongoose");
|
||||
|
||||
// VisaItem Schema
|
||||
const VisaItemSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaTypeCategory Schema
|
||||
const VisaTypeCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
category: { type: String, default: "" },
|
||||
items: [VisaItemSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
category: { type: String, default: "" },
|
||||
items: [VisaItemSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaProcessStep Schema
|
||||
const VisaProcessStepSchema = new mongoose.Schema(
|
||||
{
|
||||
number: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
number: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaProcess Schema
|
||||
const VisaProcessSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: [VisaProcessStepSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: [VisaProcessStepSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaCategory Schema
|
||||
const VisaCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: {
|
||||
type: [[String]],
|
||||
default: [],
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: {
|
||||
type: [[String]],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// VisaService Schema
|
||||
const VisaServiceSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: [VisaProcessStepSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
title: { type: String, default: "" },
|
||||
steps: [VisaProcessStepSchema],
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// RelatedCountry Schema
|
||||
const RelatedCountrySchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: Number, default: 0 },
|
||||
name: { type: String, default: "" },
|
||||
icon: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
id: { type: Number, default: 0 },
|
||||
name: { type: String, default: "" },
|
||||
icon: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Phone Schema
|
||||
const PhoneSchema = new mongoose.Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Email Schema
|
||||
const EmailSchema = new mongoose.Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
value: { type: String, default: "" },
|
||||
link: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// Location Schema
|
||||
const LocationSchema = new mongoose.Schema(
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
address: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
{
|
||||
label: { type: String, default: "" },
|
||||
address: { type: String, default: "" },
|
||||
},
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ContactInfo Schema
|
||||
const ContactInfoSchema = new mongoose.Schema(
|
||||
{
|
||||
img: { type: String, default: "" },
|
||||
sectionTitle: { type: String, default: "" },
|
||||
helpText: { type: String, default: "" },
|
||||
{
|
||||
img: { type: String, default: "" },
|
||||
sectionTitle: { type: String, default: "" },
|
||||
helpText: { type: String, default: "" },
|
||||
|
||||
phone: {
|
||||
type: PhoneSchema,
|
||||
default: () => ({}),
|
||||
phone: {
|
||||
type: PhoneSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
email: {
|
||||
type: EmailSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
location: {
|
||||
type: LocationSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
email: {
|
||||
type: EmailSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
location: {
|
||||
type: LocationSchema,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ActiveCountry Schema
|
||||
const ActiveCountrySchema = new mongoose.Schema(
|
||||
{
|
||||
id: { type: Number, default: 0 },
|
||||
name: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
mainImage: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
additionalInfo: { type: String, default: "" },
|
||||
tagline: { type: String, default: "" },
|
||||
visaTypes: [VisaTypeCategorySchema],
|
||||
visaProcess: {
|
||||
type: VisaProcessSchema,
|
||||
default: null,
|
||||
{
|
||||
id: { type: Number, default: 0 },
|
||||
name: { type: String, default: "" },
|
||||
title: { type: String, default: "" },
|
||||
mainImage: { type: String, default: "" },
|
||||
description: { type: String, default: "" },
|
||||
additionalInfo: { type: String, default: "" },
|
||||
tagline: { type: String, default: "" },
|
||||
visaTypes: [VisaTypeCategorySchema],
|
||||
visaProcess: {
|
||||
type: VisaProcessSchema,
|
||||
default: null,
|
||||
},
|
||||
gallery: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
visaCategories: {
|
||||
type: VisaCategorySchema,
|
||||
default: null,
|
||||
},
|
||||
visaService: {
|
||||
type: VisaServiceSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
gallery: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
visaCategories: {
|
||||
type: VisaCategorySchema,
|
||||
default: null,
|
||||
},
|
||||
visaService: {
|
||||
type: VisaServiceSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// DetailedView Schema
|
||||
const DetailedViewSchema = new mongoose.Schema(
|
||||
{
|
||||
activeCountry: {
|
||||
type: ActiveCountrySchema,
|
||||
default: null,
|
||||
{
|
||||
activeCountry: {
|
||||
type: ActiveCountrySchema,
|
||||
default: null,
|
||||
},
|
||||
relatedCountries: {
|
||||
type: [RelatedCountrySchema],
|
||||
default: [],
|
||||
},
|
||||
contactInfo: {
|
||||
type: ContactInfoSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
relatedCountries: {
|
||||
type: [RelatedCountrySchema],
|
||||
default: [],
|
||||
},
|
||||
contactInfo: {
|
||||
type: ContactInfoSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ==================== MAIN VISA COUNTRY SCHEMA ====================
|
||||
|
||||
// Main VisaCountry Schema (Individual country object)
|
||||
const VisaCountrySchema = new mongoose.Schema(
|
||||
{
|
||||
// Không dùng `index: true` ở đây vì đã tạo index riêng cho hero.summaryList.* bên dưới
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, required: true },
|
||||
slug: { type: String, required: true, unique: true },
|
||||
icon: { type: String, default: "" },
|
||||
services: {
|
||||
type: [String],
|
||||
default: [],
|
||||
{
|
||||
// Không dùng `index: true` ở đây vì đã tạo index riêng cho hero.summaryList.* bên dưới
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, required: true },
|
||||
slug: { type: String, required: true },
|
||||
icon: { type: String, default: "" },
|
||||
services: {
|
||||
type: [String],
|
||||
default: [],
|
||||
},
|
||||
detailedView: {
|
||||
type: DetailedViewSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
detailedView: {
|
||||
type: DetailedViewSchema,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ==================== HERO SCHEMA ====================
|
||||
|
||||
const HeroSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, default: "Visa" },
|
||||
summaryList: {
|
||||
type: [VisaCountrySchema],
|
||||
default: [],
|
||||
{
|
||||
title: { type: String, default: "Visa" },
|
||||
summaryList: {
|
||||
type: [VisaCountrySchema],
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ _id: false },
|
||||
{ _id: false },
|
||||
);
|
||||
|
||||
// ==================== MAIN VISA SCHEMA ====================
|
||||
|
||||
const visaDataSchema = new mongoose.Schema(
|
||||
{
|
||||
hero: {
|
||||
type: HeroSchema,
|
||||
default: () => ({ title: "Visa", summaryList: [] }),
|
||||
{
|
||||
hero: {
|
||||
type: HeroSchema,
|
||||
default: () => ({ title: "Visa", summaryList: [] }),
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
// ==================== INDEXES ====================
|
||||
|
||||
542
routes/admin.js
542
routes/admin.js
@@ -41,8 +41,8 @@ router.get("/home/api/blogs", ensureAuthenticated, homeController.apiGetBlogs);
|
||||
|
||||
// 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
|
||||
@@ -51,125 +51,51 @@ 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"),
|
||||
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
|
||||
router.get("/header", ensureAuthenticated, headerController.index);
|
||||
router.post("/header/update", ensureAuthenticated, headerController.update);
|
||||
router.get("/header/data", ensureAuthenticated, headerController.api); // Normalized from getHeaderData
|
||||
router.patch(
|
||||
"/header/:id/status",
|
||||
ensureAuthenticated,
|
||||
headerController.updateStatus,
|
||||
);
|
||||
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.store,
|
||||
);
|
||||
router.post(
|
||||
"/header/menu/update/:id",
|
||||
ensureAuthenticated,
|
||||
headerMenuController.update,
|
||||
);
|
||||
router.post(
|
||||
"/header/menu/delete",
|
||||
ensureAuthenticated,
|
||||
headerMenuController.destroy,
|
||||
);
|
||||
router.post(
|
||||
"/header/menu/reorder",
|
||||
ensureAuthenticated,
|
||||
headerMenuController.reorder,
|
||||
);
|
||||
router.post("/header/menu/create", ensureAuthenticated, headerMenuController.store);
|
||||
router.post("/header/menu/update/:id", ensureAuthenticated, headerMenuController.update);
|
||||
router.post("/header/menu/delete", ensureAuthenticated, headerMenuController.destroy);
|
||||
router.post("/header/menu/reorder", ensureAuthenticated, headerMenuController.reorder);
|
||||
|
||||
// Social Links routes
|
||||
router.get("/social-links", ensureAuthenticated, socialLinkController.index);
|
||||
router.post("/social-links", ensureAuthenticated, socialLinkController.store);
|
||||
router.put(
|
||||
"/social-links/:platform",
|
||||
ensureAuthenticated,
|
||||
socialLinkController.update,
|
||||
);
|
||||
router.delete(
|
||||
"/social-links/:platform",
|
||||
ensureAuthenticated,
|
||||
socialLinkController.destroy,
|
||||
);
|
||||
router.post(
|
||||
"/social-links/reorder",
|
||||
ensureAuthenticated,
|
||||
socialLinkController.reorder,
|
||||
);
|
||||
router.put("/social-links/:platform", ensureAuthenticated, socialLinkController.update);
|
||||
router.delete("/social-links/:platform", ensureAuthenticated, socialLinkController.destroy);
|
||||
router.post("/social-links/reorder", ensureAuthenticated, socialLinkController.reorder);
|
||||
|
||||
// Footer routes
|
||||
router.get("/footer", ensureAuthenticated, footerController.index);
|
||||
@@ -179,160 +105,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);
|
||||
@@ -342,16 +168,8 @@ router.get("/faq/api", faqController.api);
|
||||
|
||||
// API routes cho quản lý FAQ items (AJAX calls)
|
||||
router.post("/faq/api/add-faq", ensureAuthenticated, faqController.addFAQ);
|
||||
router.put(
|
||||
"/faq/api/update-faq-item/:sectionId/:faqId",
|
||||
ensureAuthenticated,
|
||||
faqController.updateFAQItem,
|
||||
);
|
||||
router.delete(
|
||||
"/faq/api/delete-faq-item/:sectionId/:faqId",
|
||||
ensureAuthenticated,
|
||||
faqController.deleteFAQItem,
|
||||
);
|
||||
router.put("/faq/api/update-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.updateFAQItem);
|
||||
router.delete("/faq/api/delete-faq-item/:sectionId/:faqId", ensureAuthenticated, faqController.deleteFAQItem);
|
||||
router.get("/terms-conditions", ensureAuthenticated, termsController.index);
|
||||
router.post("/terms/update", ensureAuthenticated, termsController.update);
|
||||
router.get("/terms/data", ensureAuthenticated, termsController.getTermsData);
|
||||
@@ -367,33 +185,13 @@ router.get("/travel/api", travelController.api);
|
||||
router.get("/travel/seed", ensureAuthenticated, travelController.seed);
|
||||
|
||||
// API routes cho quản lý FAQ sections (AJAX calls)
|
||||
router.post(
|
||||
"/faq/api/add-section",
|
||||
ensureAuthenticated,
|
||||
faqController.addFAQSection,
|
||||
);
|
||||
router.put(
|
||||
"/faq/api/update-section/:sectionId",
|
||||
ensureAuthenticated,
|
||||
faqController.updateFAQSection,
|
||||
);
|
||||
router.delete(
|
||||
"/faq/api/delete-section/:sectionId",
|
||||
ensureAuthenticated,
|
||||
faqController.deleteFAQSection,
|
||||
);
|
||||
router.post(
|
||||
"/faq/api/reorder-sections",
|
||||
ensureAuthenticated,
|
||||
faqController.reorderFAQSection,
|
||||
);
|
||||
router.post("/faq/api/add-section", ensureAuthenticated, faqController.addFAQSection);
|
||||
router.put("/faq/api/update-section/:sectionId", ensureAuthenticated, faqController.updateFAQSection);
|
||||
router.delete("/faq/api/delete-section/:sectionId", ensureAuthenticated, faqController.deleteFAQSection);
|
||||
router.post("/faq/api/reorder-sections", ensureAuthenticated, faqController.reorderFAQSection);
|
||||
|
||||
// API routes cho sidebar navigation (AJAX calls)
|
||||
router.put(
|
||||
"/faq/api/update-sidebar",
|
||||
ensureAuthenticated,
|
||||
faqController.updateSidebarNav,
|
||||
);
|
||||
router.put("/faq/api/update-sidebar", ensureAuthenticated, faqController.updateSidebarNav);
|
||||
|
||||
// Safety routes
|
||||
router.get("/safety", ensureAuthenticated, safetyController.index);
|
||||
@@ -401,98 +199,74 @@ router.post("/safety/update", ensureAuthenticated, safetyController.update);
|
||||
|
||||
//Insurance routes
|
||||
router.get("/insurance", ensureAuthenticated, insuranceController.index);
|
||||
router.post(
|
||||
"/insurance/update",
|
||||
ensureAuthenticated,
|
||||
insuranceController.update,
|
||||
);
|
||||
router.post("/insurance/update", ensureAuthenticated, insuranceController.update);
|
||||
|
||||
// Service routes
|
||||
router.get("/service", ensureAuthenticated, serviceController.index);
|
||||
router.post("/service/update", ensureAuthenticated, serviceController.update);
|
||||
router.post(
|
||||
"/service/generate-slug",
|
||||
ensureAuthenticated,
|
||||
serviceController.generateSlug,
|
||||
);
|
||||
router.post("/service/generate-slug", ensureAuthenticated, serviceController.generateSlug);
|
||||
router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
|
||||
router.post(
|
||||
"/service/:slug/edit",
|
||||
ensureAuthenticated,
|
||||
serviceController.updateService,
|
||||
);
|
||||
router.get(
|
||||
"/service/:slug/details",
|
||||
ensureAuthenticated,
|
||||
serviceController.details,
|
||||
);
|
||||
router.post(
|
||||
"/service/:slug/details/update",
|
||||
ensureAuthenticated,
|
||||
serviceController.updateDetails,
|
||||
);
|
||||
router.post("/service/:slug/edit", ensureAuthenticated, serviceController.updateService);
|
||||
router.get("/service/:slug/details", ensureAuthenticated, serviceController.details);
|
||||
router.post("/service/:slug/details/update", ensureAuthenticated, serviceController.updateDetails);
|
||||
|
||||
// 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
|
||||
@@ -508,18 +282,10 @@ router.post("/visa/update", ensureAuthenticated, visaController.updateCountry);
|
||||
router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
|
||||
|
||||
// Update single country
|
||||
router.put(
|
||||
"/visa/update/:id",
|
||||
ensureAuthenticated,
|
||||
visaController.updateCountry,
|
||||
);
|
||||
router.put("/visa/update/:id", ensureAuthenticated, visaController.updateCountry);
|
||||
|
||||
// Delete country
|
||||
router.delete(
|
||||
"/visa/delete/:id",
|
||||
ensureAuthenticated,
|
||||
visaController.deleteCountry,
|
||||
);
|
||||
router.delete("/visa/delete/:id", ensureAuthenticated, visaController.deleteCountry);
|
||||
// Blog routes
|
||||
// Blog Management Routes
|
||||
router.get("/blog", ensureAuthenticated, blogController.index);
|
||||
@@ -530,78 +296,26 @@ router.post("/blog/:id/edit", ensureAuthenticated, blogController.update);
|
||||
router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
|
||||
|
||||
// Comment management routes
|
||||
router.post(
|
||||
"/blog/:blogId/comments/:commentId/approve",
|
||||
ensureAuthenticated,
|
||||
blogController.approveComment,
|
||||
);
|
||||
router.post(
|
||||
"/blog/:blogId/comments/:commentId/reject",
|
||||
ensureAuthenticated,
|
||||
blogController.rejectComment,
|
||||
);
|
||||
router.post(
|
||||
"/blog/:blogId/comments/:commentId/delete",
|
||||
ensureAuthenticated,
|
||||
blogController.deleteComment,
|
||||
);
|
||||
router.post("/blog/:blogId/comments/:commentId/approve", ensureAuthenticated, blogController.approveComment);
|
||||
router.post("/blog/:blogId/comments/:commentId/reject", ensureAuthenticated, blogController.rejectComment);
|
||||
router.post("/blog/:blogId/comments/:commentId/delete", ensureAuthenticated, blogController.deleteComment);
|
||||
|
||||
// Blog Categories Management
|
||||
router.get(
|
||||
"/blog/categories",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.index,
|
||||
);
|
||||
router.get(
|
||||
"/blog/categories/create",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.create,
|
||||
);
|
||||
router.post(
|
||||
"/blog/categories/create",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.store,
|
||||
);
|
||||
router.get(
|
||||
"/blog/categories/:id/edit",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.edit,
|
||||
);
|
||||
router.post(
|
||||
"/blog/categories/:id/edit",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.update,
|
||||
);
|
||||
router.post(
|
||||
"/blog/categories/:id/delete",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.destroy,
|
||||
);
|
||||
router.post(
|
||||
"/blog/categories/quick-create",
|
||||
ensureAuthenticated,
|
||||
blogCategoryController.quickCreate,
|
||||
);
|
||||
router.get("/blog/categories", ensureAuthenticated, blogCategoryController.index);
|
||||
router.get("/blog/categories/create", ensureAuthenticated, blogCategoryController.create);
|
||||
router.post("/blog/categories/create", ensureAuthenticated, blogCategoryController.store);
|
||||
router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit);
|
||||
router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update);
|
||||
router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy);
|
||||
router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate);
|
||||
|
||||
// Blog Tags Management
|
||||
router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
|
||||
router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create);
|
||||
router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
|
||||
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
|
||||
router.post(
|
||||
"/blog/tags/:id/edit",
|
||||
ensureAuthenticated,
|
||||
blogTagController.update,
|
||||
);
|
||||
router.post(
|
||||
"/blog/tags/:id/delete",
|
||||
ensureAuthenticated,
|
||||
blogTagController.destroy,
|
||||
);
|
||||
router.post(
|
||||
"/blog/tags/quick-create",
|
||||
ensureAuthenticated,
|
||||
blogTagController.quickCreate,
|
||||
);
|
||||
router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update);
|
||||
router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy);
|
||||
router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -58,8 +58,9 @@ router.get("/api/header-menu", headerMenuController.api);
|
||||
router.get("/api/social-links", socialLinkController.index);
|
||||
router.get("/api/social-links/:platform", socialLinkController.show);
|
||||
|
||||
// Footer API route
|
||||
router.get("/api/footer", footerController.api);
|
||||
// Footer API routes
|
||||
router.get("/api/footer", footerController.getFooter);
|
||||
router.put("/api/admin/footer", footerController.updateFooter);
|
||||
|
||||
// Contact API route
|
||||
router.get("/api/contact", contactController.api);
|
||||
@@ -175,4 +176,12 @@ router.get("/api/service-slugs", serviceController.getServiceSlugs);
|
||||
router.get("/api/visa", visaController.api);
|
||||
router.get("/api/visa/country", visaController.apiCountries);
|
||||
|
||||
// Test route for footer
|
||||
router.get("/test-footer", (req, res) => {
|
||||
res.render("test-footer", {
|
||||
title: "Footer Test",
|
||||
layout: "layouts/main",
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
68
scripts/2026_02_05_120000_footer.js
Normal file
68
scripts/2026_02_05_120000_footer.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const mongoose = require("mongoose");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Import model
|
||||
const Footer = require("../models/footer");
|
||||
|
||||
/**
|
||||
* Migration script để import dữ liệu footer từ JSON
|
||||
*/
|
||||
async function up() {
|
||||
try {
|
||||
console.log("Starting footer migration...");
|
||||
|
||||
// Đọc dữ liệu từ file JSON
|
||||
const jsonPath = path.join(__dirname, "../data/footer.json");
|
||||
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
throw new Error("Footer JSON file not found");
|
||||
}
|
||||
|
||||
const footerData = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
|
||||
|
||||
// Sử dụng static method từ model để migrate
|
||||
const result = await Footer.migrateFromJson(footerData);
|
||||
|
||||
console.log("Footer migration completed successfully");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Footer migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback migration
|
||||
*/
|
||||
async function down() {
|
||||
try {
|
||||
console.log("Rolling back footer migration...");
|
||||
|
||||
// Xóa footer data
|
||||
await Footer.deleteMany({});
|
||||
|
||||
console.log("Footer rollback completed");
|
||||
} catch (error) {
|
||||
console.error("Footer rollback failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { up, down };
|
||||
|
||||
// Chạy migration nếu file được gọi trực tiếp
|
||||
if (require.main === module) {
|
||||
const connectDB = require("../config/database");
|
||||
|
||||
connectDB()
|
||||
.then(() => up())
|
||||
.then(() => {
|
||||
console.log("Migration completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
90
scripts/2026_02_05_130000_add_footer_menu_order.js
Normal file
90
scripts/2026_02_05_130000_add_footer_menu_order.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const mongoose = require("mongoose");
|
||||
const Footer = require("../models/footer");
|
||||
const footerData = require("../data/footer.json");
|
||||
|
||||
async function addFooterMenuOrder() {
|
||||
try {
|
||||
console.log("=== Adding order field to Footer Menu Links ===");
|
||||
|
||||
// Connect to database
|
||||
await mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning");
|
||||
console.log("✓ Connected to MongoDB");
|
||||
|
||||
// Get existing footer or create from JSON
|
||||
let footer = await Footer.findOne();
|
||||
|
||||
if (!footer) {
|
||||
console.log("No existing footer found, creating from JSON data...");
|
||||
|
||||
// Add order to bottom menu links
|
||||
if (footerData.bottom && footerData.bottom.menuLinks) {
|
||||
footerData.bottom.menuLinks = footerData.bottom.menuLinks.map((link, index) => ({
|
||||
...link,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// Add order to top menu links
|
||||
if (footerData.top && footerData.top.menuLinks) {
|
||||
footerData.top.menuLinks = footerData.top.menuLinks.map((link, index) => ({
|
||||
...link,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
footer = await Footer.create(footerData);
|
||||
console.log("✓ Footer created with order fields");
|
||||
} else {
|
||||
console.log("Found existing footer, adding order fields...");
|
||||
|
||||
// Add order to bottom menu links
|
||||
if (footer.bottom && footer.bottom.menuLinks) {
|
||||
footer.bottom.menuLinks = footer.bottom.menuLinks.map((link, index) => ({
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
order: link.order || index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// Add order to top menu links
|
||||
if (footer.top && footer.top.menuLinks) {
|
||||
footer.top.menuLinks = footer.top.menuLinks.map((link, index) => ({
|
||||
label: link.label,
|
||||
href: link.href,
|
||||
order: link.order || index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
await footer.save();
|
||||
console.log("✓ Footer updated with order fields");
|
||||
}
|
||||
|
||||
console.log("Bottom Menu Links with order:");
|
||||
footer.bottom.menuLinks.forEach((link, index) => {
|
||||
console.log(` ${index + 1}. ${link.label} (order: ${link.order}) -> ${link.href}`);
|
||||
});
|
||||
|
||||
console.log("=== Footer Menu Order Migration Completed ===");
|
||||
} catch (error) {
|
||||
console.error("✗ Migration failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await mongoose.disconnect();
|
||||
console.log("✓ Disconnected from MongoDB");
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration if called directly
|
||||
if (require.main === module) {
|
||||
addFooterMenuOrder()
|
||||
.then(() => {
|
||||
console.log("Migration completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = addFooterMenuOrder;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -820,45 +820,18 @@
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-5">
|
||||
<footer class="mt-5" id="dynamicFooter">
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h5 class="mb-3">CMS.HAILearning</h5>
|
||||
<p>Simple and effective API management system.</p>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h5 class="mb-3">Links</h5>
|
||||
<ul class="list-unstyled">
|
||||
<% if (locals.user) { %>
|
||||
<li class="mb-2">
|
||||
<a href="/admin/dashboard" class="text-decoration-none hover-opacity">Dashboard</a>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<li class="mb-2">
|
||||
<a href="/auth/login" class="text-decoration-none hover-opacity">Login</a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h5 class="mb-3">Contact</h5>
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-map-marker-alt me-2"></i> 734 Luy Ban Bich St., Tan Thanh Ward, Tan Phu Dist, HCMC
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-envelope me-2"></i> <a href="mailto:get-info@hai.edu.vn" class="text-decoration-none hover-opacity">get-info@hai.edu.vn</a>
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-globe me-2"></i> <a href="https://hai.edu.vn" target="_blank" class="text-decoration-none hover-opacity">hai.edu.vn</a>
|
||||
</p>
|
||||
<div class="row" id="footerContent">
|
||||
<!-- Footer content will be loaded from API -->
|
||||
<div class="col-12 text-center">
|
||||
<p>Loading footer...</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4" />
|
||||
<div class="text-center">
|
||||
<p class="mb-0">
|
||||
© <%= new Date().getFullYear() %> CMS.HAILearning. All rights
|
||||
reserved.
|
||||
<p class="mb-0" id="footerCopyright">
|
||||
© <%= new Date().getFullYear() %> Loading...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1018,6 +991,198 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Footer Dynamic Loading Script -->
|
||||
<script>
|
||||
// Load and render footer from API
|
||||
async function loadFooter() {
|
||||
try {
|
||||
const response = await fetch('/api/footer');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load footer data');
|
||||
}
|
||||
|
||||
const footerData = await response.json();
|
||||
renderFooter(footerData);
|
||||
} catch (error) {
|
||||
console.error('Error loading footer:', error);
|
||||
renderFallbackFooter();
|
||||
}
|
||||
}
|
||||
|
||||
// Render footer with API data
|
||||
function renderFooter(data) {
|
||||
const footerContent = document.getElementById('footerContent');
|
||||
const footerCopyright = document.getElementById('footerCopyright');
|
||||
|
||||
if (!footerContent || !footerCopyright) return;
|
||||
|
||||
let footerHTML = '';
|
||||
|
||||
// About section (first column)
|
||||
if (data.about && (data.about.title || data.about.description)) {
|
||||
footerHTML += `
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h5 class="mb-3">${escapeHtml(data.about.title || 'About Us')}</h5>
|
||||
${data.about.description ? `<p>${escapeHtml(data.about.description)}</p>` : ''}
|
||||
${data.about.mapLink && data.about.mapLink.url ?
|
||||
`<p><a href="${escapeHtml(data.about.mapLink.url)}" target="_blank" class="text-decoration-none hover-opacity">
|
||||
<i class="fas fa-map-marker-alt me-2"></i>${escapeHtml(data.about.mapLink.text || 'View on Map')}
|
||||
</a></p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Footer columns
|
||||
if (data.columns && Array.isArray(data.columns)) {
|
||||
// Sort columns by order if available, otherwise maintain array order
|
||||
const sortedColumns = data.columns.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
sortedColumns.forEach(column => {
|
||||
if (column.title || (column.links && column.links.length > 0)) {
|
||||
footerHTML += `
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
${column.title ? `<h5 class="mb-3">${escapeHtml(column.title)}</h5>` : ''}
|
||||
${column.links && column.links.length > 0 ? `
|
||||
<ul class="list-unstyled">
|
||||
${column.links.map(link =>
|
||||
`<li class="mb-2">
|
||||
<a href="${escapeHtml(link.url || '#')}" class="text-decoration-none hover-opacity">
|
||||
${escapeHtml(link.title || 'Link')}
|
||||
</a>
|
||||
</li>`
|
||||
).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Contact section
|
||||
if (data.contact && (data.contact.email || data.contact.phone)) {
|
||||
footerHTML += `
|
||||
<div class="col-md-4">
|
||||
<h5 class="mb-3">Contact</h5>
|
||||
${data.contact.email ? `
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-envelope me-2"></i>
|
||||
<a href="mailto:${escapeHtml(data.contact.email)}" class="text-decoration-none">
|
||||
${escapeHtml(data.contact.email)}
|
||||
</a>
|
||||
</p>
|
||||
` : ''}
|
||||
${data.contact.phone ? `
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-phone me-2"></i>
|
||||
<a href="tel:${escapeHtml(data.contact.phone)}" class="text-decoration-none">
|
||||
${escapeHtml(data.contact.phone)}
|
||||
</a>
|
||||
</p>
|
||||
` : ''}
|
||||
${data.contact.hours ? `
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-clock me-2"></i> ${escapeHtml(data.contact.hours)}
|
||||
</p>
|
||||
` : ''}
|
||||
${data.address && data.address.text ? `
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-map-marker-alt me-2"></i>
|
||||
${data.address.mapUrl ?
|
||||
`<a href="${escapeHtml(data.address.mapUrl)}" target="_blank" class="text-decoration-none">
|
||||
${escapeHtml(data.address.text)}
|
||||
</a>` :
|
||||
escapeHtml(data.address.text)
|
||||
}
|
||||
</p>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Social links (if any)
|
||||
if (data.social && data.social.links && Array.isArray(data.social.links) && data.social.links.length > 0) {
|
||||
// Sort social links by order if available
|
||||
const sortedSocials = data.social.links.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
footerHTML += `
|
||||
<div class="col-12 mt-3">
|
||||
<div class="text-center">
|
||||
<h6 class="mb-3">Follow Us</h6>
|
||||
<div class="d-flex justify-content-center gap-3">
|
||||
${sortedSocials.map(social =>
|
||||
`<a href="${escapeHtml(social.url || '#')}" target="_blank"
|
||||
class="text-decoration-none hover-opacity"
|
||||
title="${escapeHtml(social.platform || 'Social Link')}">
|
||||
<i class="fab fa-${escapeHtml(social.icon || 'link')} fa-lg"></i>
|
||||
</a>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Update footer content
|
||||
footerContent.innerHTML = footerHTML;
|
||||
|
||||
// Update copyright
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = data.copyright && data.copyright.text ?
|
||||
data.copyright.text :
|
||||
`© ${currentYear} Website. All rights reserved.`;
|
||||
|
||||
footerCopyright.innerHTML = copyrightText;
|
||||
}
|
||||
|
||||
// Fallback footer if API fails
|
||||
function renderFallbackFooter() {
|
||||
const footerContent = document.getElementById('footerContent');
|
||||
const footerCopyright = document.getElementById('footerCopyright');
|
||||
|
||||
if (footerContent) {
|
||||
footerContent.innerHTML = `
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h5 class="mb-3">CMS-GGCamp</h5>
|
||||
<p>Simple and effective API management system.</p>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4 mb-md-0">
|
||||
<h5 class="mb-3">Links</h5>
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<a href="/auth/login" class="text-decoration-none hover-opacity">Login</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h5 class="mb-3">Contact</h5>
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-envelope me-2"></i> office@ggcamp.org
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-phone me-2"></i> 12345678
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (footerCopyright) {
|
||||
footerCopyright.innerHTML = `© ${new Date().getFullYear()} CMS-GGCamp. All rights reserved.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Load footer when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', loadFooter);
|
||||
</script>
|
||||
|
||||
<%- script %>
|
||||
</body>
|
||||
|
||||
|
||||
98
views/test-footer.ejs
Normal file
98
views/test-footer.ejs
Normal file
@@ -0,0 +1,98 @@
|
||||
<div class="container my-5">
|
||||
<h1>🧪 Test Footer Implementation</h1>
|
||||
<p>Trang này test việc render footer từ backend API trong layout chính</p>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h5>📊 Kiểm tra các yếu tố:</h5>
|
||||
<ul class="mb-0">
|
||||
<li>✅ Footer được load từ <code>GET /api/footer</code></li>
|
||||
<li>✅ Render đúng các section: About, Columns, Contact, Social</li>
|
||||
<li>✅ Tôn trọng thứ tự (order) của columns và social links</li>
|
||||
<li>✅ XSS protection với escapeHtml()</li>
|
||||
<li>✅ Fallback footer nếu API lỗi</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>🔗 API Test</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-primary" onclick="testFooterAPI()">Test API</button>
|
||||
<div id="apiResult" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>📱 Footer Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="footerStatus">
|
||||
<p>Footer sẽ được load tự động khi trang tải xong.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testFooterAPI() {
|
||||
const resultDiv = document.getElementById("apiResult");
|
||||
resultDiv.innerHTML = "<p>⏳ Đang test API...</p>";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/footer");
|
||||
const data = await response.json();
|
||||
|
||||
let html = '<div class="alert alert-success">';
|
||||
html += "<h6>✅ API Response thành công:</h6>";
|
||||
html += `<p><strong>About:</strong> ${data.about?.title || "N/A"}</p>`;
|
||||
html += `<p><strong>Columns:</strong> ${data.columns?.length || 0} cột</p>`;
|
||||
html += `<p><strong>Social:</strong> ${data.social?.links?.length || 0} links</p>`;
|
||||
html += `<p><strong>Contact:</strong> ${data.contact?.email || "N/A"}</p>`;
|
||||
html += "</div>";
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Lỗi: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor footer loading
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const statusDiv = document.getElementById("footerStatus");
|
||||
|
||||
// Check if footer elements exist
|
||||
const footerContent = document.getElementById("footerContent");
|
||||
const footerCopyright = document.getElementById("footerCopyright");
|
||||
|
||||
if (footerContent && footerCopyright) {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
✅ Footer elements found<br>
|
||||
✅ Dynamic loading script active<br>
|
||||
✅ Footer sẽ được render từ API
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Monitor footer content changes
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.target.id === "footerContent" && mutation.target.innerHTML.includes("About")) {
|
||||
statusDiv.innerHTML +=
|
||||
'<div class="alert alert-info mt-2">🎉 Footer đã được render thành công!</div>';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(footerContent, { childList: true, subtree: true });
|
||||
} else {
|
||||
statusDiv.innerHTML = '<div class="alert alert-warning">⚠️ Footer elements không tìm thấy</div>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user