Merge branch 'main' of https://gits.techvanguard.vn/UKSOURCE/cms.hailearning.edu.vn into hoanganh-03022026-appointment-contact-pricing

This commit is contained in:
LNHA
2026-02-05 16:18:58 +07:00
11 changed files with 2677 additions and 1703 deletions

View File

@@ -1,88 +1,87 @@
const { addBaseUrlToImages } = require("../utils/imageHelper"); const { addBaseUrlToImages } = require("../utils/imageHelper");
const Footer = require("../models/footer"); const Footer = require("../models/footer");
// Get footer data from MongoDB // GET /api/footer - Public API cho website và CMS load dữ liệu
const getFooterData = async () => { exports.getFooter = async (req, res) => {
const footer = await Footer.findOne({ name: "default" });
if (!footer) {
return {
logo: {
src: '',
alt: ''
},
about: {
title: "About GGC",
description: "",
mapLink: {
text: "Check on google map",
url: "",
},
},
address: {
text: "",
address2: "",
mapUrl: "",
},
contact: {
phone: "",
hours: "",
email: "",
},
columns: [],
social: {
links: [],
},
copyright: {
text: "",
},
};
}
return footer.toObject();
};
// API to get footer data
exports.api = async (req, res) => {
try { try {
// Lấy footer data const footer = await Footer.getSingle();
const footer = await getFooterData(); const processedData = addBaseUrlToImages(footer.toObject());
// Xử lý URL cho logo và các hình ảnh khác
const processedData = addBaseUrlToImages(footer);
res.json(processedData); res.json(processedData);
} catch (err) {
console.error("API Error:", err);
res.status(500).json({ error: "Error loading footer data" });
}
};
// API để lấy toàn bộ footer data
exports.getFooterData = async (req, res) => {
try {
const footerData = await getFooterData();
const processed = addBaseUrlToImages(footerData);
res.json(processed);
} catch (error) { } catch (error) {
console.error("Error getting footer data:", error); console.error("Error getting footer:", error);
res.status(500).json({ error: "Error loading footer data" }); res.status(500).json({
error: "Failed to get footer data",
});
} }
}; };
// Render admin view // PUT /api/admin/footer - Update toàn bộ footer cho CMS
exports.updateFooter = async (req, res) => {
try {
let updateData = req.body;
console.log("=== FOOTER UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
// 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,
});
}
};
// Render admin view (giữ lại cho UI hiện tại)
exports.index = async (req, res) => { exports.index = async (req, res) => {
try { try {
const data = await getFooterData(); const data = await Footer.getSingle();
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const processedData = addBaseUrlToImages(data.toObject());
// Ensure image paths are absolute for admin preview
const processedData = addBaseUrlToImages(data);
res.render("admin/footer/index", { res.render("admin/footer/index", {
title: "Footer Management", title: "Footer Management",
data data: processedData,
}); });
} catch (error) { } catch (error) {
console.error("Error in footer index:", error); console.error("Error in footer index:", error);
@@ -91,88 +90,54 @@ exports.index = async (req, res) => {
} }
}; };
// 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) => { exports.update = async (req, res) => {
try { try {
let footerData = req.body; let updateData = req.body;
if (footerData.footerJson) {
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 { try {
footerData = JSON.parse(footerData.footerJson); const parsedData = JSON.parse(updateData.footerJson);
} catch (err) { console.log("✓ Parsed footerJson successfully:", parsedData);
console.warn('Invalid footerJson payload, falling back to req.body'); 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");
} }
} }
// Tìm footer hiện hoặc tạo mới nếu không tồn tại // Lấy footer hiện tại hoặc tạo mới (giống Header)
let footer = await Footer.findOne({ name: "default" }); let footer = await Footer.findOne();
if (!footer) { if (!footer) {
footer = new Footer({ console.log("No existing footer found, creating new one");
name: "default", footer = new Footer(updateData);
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(); 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"); req.flash("success_msg", "Footer updated successfully");
}
// Redirect back to the active tab const activeTab = req.body.activeTab || "about";
const activeTab = req.body.activeTab || 'about';
res.redirect(`/admin/footer?activeTab=${activeTab}`); res.redirect(`/admin/footer?activeTab=${activeTab}`);
} catch (err) { } catch (err) {
console.error("Error updating footer:", err); console.error("Error updating footer:", err);
req.flash("error_msg", err.message || "Error updating footer"); req.flash("error_msg", err.message || "Error updating footer");
res.redirect("/admin/footer"); res.redirect("/admin/footer");
} }
}; };
// Legacy API endpoints (giữ lại cho tương thích)
exports.api = exports.getFooter;
exports.getFooterData = exports.getFooter;

View File

@@ -1,80 +1,80 @@
{ {
"about": { "top": {
"title": "About GGC", "bgImage": "/assets/img/home-1/footer-bg.jpg",
"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", "phone": {
"mapLink": { "display": "+84 961 83 4040",
"text": "Check on google map", "href": "tel:+84961834040"
"url": "https://share.google/8G4LFRq82OwOmlFlN"
}
}, },
"address": { "address": "734 Luy Ban Bich St, Tan Thanh Ward, Tan Phu Dist, HCMC",
"text": "Poblacion, Madridejos 22, Cebu City, Philippines", "logo": {
"address2": "", "src": "/assets/img/logo/white-logo.svg",
"link2": "", "alt": "logo",
"mapUrl": "https://share.google/8G4LFRq82OwOmlFlN" "href": "/"
}, },
"contact": { "menuLinks": [
"phone": "+123456789",
"hours": "Mon - Fri 8.00-18.00",
"email": "office@ggcamp.org"
},
"columns": [
{ {
"title": "Explore", "label": "Home",
"links": [ "href": "/"
{
"title": "Home",
"url": "/"
}, },
{ {
"title": "Activities", "label": "About Us",
"url": "/destinations" "href": "/about"
}, },
{ {
"title": "Camp Locations", "label": "Visa",
"url": "/camp-profiles" "href": "/country-details"
}, },
{ {
"title": "About", "label": "Pages",
"url": "/info/about" "href": "/news-details"
}, },
{ {
"title": "Contact", "label": "Article",
"url": "/info/contact" "href": "/news"
} },
] {
"label": "Contact Us",
"href": "/contact"
} }
], ],
"social": { "socialLinks": [
"links": [
{ {
"platform": "Facebook", "icon": "fa-brands fa-twitter",
"url": "https://www.facebook.com/campadventuregermany/", "href": "#"
"icon": "facebook"
}, },
{ {
"platform": "Twitter", "icon": "fa-brands fa-instagram",
"url": "#", "href": "#"
"icon": "twitter"
}, },
{ {
"platform": "Instagram", "icon": "fa-brands fa-linkedin",
"url": "https://www.instagram.com/campadventuregermany/", "href": "#"
"icon": "instagram"
}, },
{ {
"platform": "Behance", "icon": "fa-brands fa-youtube",
"url": "#", "href": "#"
"icon": "behance"
},
{
"platform": "LinkedIn",
"url": "#",
"icon": "linkedin"
} }
] ]
}, },
"bottom": {
"copyright": { "copyright": {
"text": "© 2025 GGC @ All Rights Reserved" "text": "Copyright©",
"brand": "GRAMENTHEME",
"rights": "All Rights Reserved."
},
"menuLinks": [
{
"label": "Terms & Conditions",
"href": "/contact"
},
{
"label": "Privacy Policy",
"href": "/contact"
},
{
"label": "Contact Us",
"href": "/contact"
}
]
} }
} }

View File

@@ -1,58 +1,61 @@
const mongoose = require("mongoose"); 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 // Schema cho social links
const socialLinkSchema = new mongoose.Schema( const socialLinkSchema = new mongoose.Schema(
{ {
platform: {
type: String,
required: true,
trim: true,
},
url: {
type: String,
required: true,
trim: true,
},
icon: { icon: {
type: String, type: String,
required: true, required: true,
trim: true, trim: true,
}, },
href: {
type: String,
required: true,
trim: true,
}, },
{ _id: false } },
{ _id: false },
); );
// Schema cho footer links // Schema cho phone
const footerLinkSchema = new mongoose.Schema( const phoneSchema = new mongoose.Schema(
{ {
title: { display: {
type: String, type: String,
required: true, required: false,
trim: true, trim: true,
default: "",
}, },
url: { href: {
type: String, type: String,
required: true, required: false,
trim: true, trim: true,
default: "",
}, },
}, },
{ _id: false } { _id: false },
);
// Schema cho footer columns
const footerColumnSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true,
},
links: {
type: [footerLinkSchema],
default: [],
},
},
{ _id: false }
); );
// Schema cho logo // Schema cho logo
@@ -60,67 +63,24 @@ const logoSchema = new mongoose.Schema(
{ {
src: { src: {
type: String, type: String,
required: true, required: false,
trim: true, trim: true,
default: "",
}, },
alt: { alt: {
type: String, type: String,
required: true, required: false,
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, trim: true,
default: "", default: "",
}, },
// Optional secondary link (e.g., a second map or external resource) href: {
link2: {
type: String, type: String,
required: false,
trim: true, trim: true,
default: "", default: "/",
},
mapUrl: {
type: String,
required: true,
trim: true,
}, },
}, },
{ _id: false } { _id: false },
);
// Schema cho contact info
const contactInfoSchema = new mongoose.Schema(
{
phone: {
type: String,
required: true,
trim: true,
},
hours: {
type: String,
required: true,
trim: true,
},
email: {
type: String,
required: true,
trim: true,
},
},
{ _id: false }
); );
// Schema cho copyright // Schema cho copyright
@@ -128,103 +88,122 @@ const copyrightSchema = new mongoose.Schema(
{ {
text: { text: {
type: String, type: String,
required: true, required: false,
trim: true, 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) // Schema cho top section
const aboutSchema = new mongoose.Schema( const topSchema = new mongoose.Schema(
{ {
title: { bgImage: {
type: String, type: String,
required: true, required: false,
trim: true, trim: true,
default: "",
}, },
description: { phone: {
type: String, type: phoneSchema,
required: true, default: () => ({ display: "", href: "" }),
trim: true,
},
mapLink: {
text: {
type: String,
required: true,
trim: true,
},
url: {
type: String,
required: true,
trim: true,
},
},
},
{ _id: false }
);
// Main Footer Schema
const footerSchema = new mongoose.Schema(
{
name: {
type: String,
default: "default",
unique: true,
},
about: {
type: aboutSchema,
required: true,
}, },
address: { address: {
type: addressSchema, type: String,
required: true, required: false,
trim: true,
default: "",
}, },
contact: { logo: {
type: contactInfoSchema, type: logoSchema,
required: true, default: () => ({ src: "", alt: "", href: "/" }),
}, },
columns: { menuLinks: {
type: [footerColumnSchema], type: [menuLinkSchema],
default: [], default: [],
}, },
social: { socialLinks: {
links: {
type: [socialLinkSchema], type: [socialLinkSchema],
default: [], default: [],
}, },
}, },
{ _id: false },
);
// Schema cho bottom section
const bottomSchema = new mongoose.Schema(
{
copyright: { copyright: {
type: copyrightSchema, type: copyrightSchema,
required: true, 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(
{
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: [],
}),
}, },
}, },
{ {
timestamps: true, timestamps: true,
} },
); );
// Tạo migration script để import dữ liệu từ footer.json // 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) { footerSchema.statics.migrateFromJson = async function (jsonData) {
try { try {
// Kiểm tra xem đã có footer mặc định chưa // Xóa tất cả documents hiện có
const existingFooter = await this.findOne({ name: "default" }); await this.deleteMany({});
if (existingFooter) { // Tạo document mới
// Cập nhật footer hiện có const footer = await this.create(jsonData);
Object.assign(existingFooter, jsonData); console.log("Footer data migrated successfully");
await existingFooter.save(); return footer;
console.log("Footer data updated successfully");
return existingFooter;
} else {
// Tạo footer mới với dữ liệu từ JSON
const newFooter = await this.create({
name: "default",
...jsonData,
});
console.log("Footer data imported successfully");
return newFooter;
}
} catch (error) { } catch (error) {
console.error("Error migrating footer data:", error); console.error("Error migrating footer data:", error);
throw error; throw error;

View File

@@ -182,7 +182,7 @@ 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 // 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 }, id: { type: Number, required: true },
name: { type: String, required: true }, name: { type: String, required: true },
slug: { type: String, required: true, unique: true }, slug: { type: String, required: true },
icon: { type: String, default: "" }, icon: { type: String, default: "" },
services: { services: {
type: [String], type: [String],

View File

@@ -54,42 +54,18 @@ router.post("/about/update", ensureAuthenticated, aboutController.update);
// AboutUs admin CRUD // AboutUs admin CRUD
router.get("/about-us", ensureAuthenticated, aboutUsController.index); router.get("/about-us", ensureAuthenticated, aboutUsController.index);
router.get( router.get("/about-us/create", ensureAuthenticated, aboutUsController.createForm);
"/about-us/create",
ensureAuthenticated,
aboutUsController.createForm,
);
router.post("/about-us/create", ensureAuthenticated, aboutUsController.create); router.post("/about-us/create", ensureAuthenticated, aboutUsController.create);
router.get( router.get("/about-us/:id/edit", ensureAuthenticated, aboutUsController.editForm);
"/about-us/:id/edit", router.post("/about-us/:id/update", ensureAuthenticated, aboutUsController.update);
ensureAuthenticated, router.post("/about-us/:id/delete", ensureAuthenticated, aboutUsController.delete);
aboutUsController.editForm, router.get("/about-us/:id/preview", ensureAuthenticated, aboutUsController.preview);
);
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 // Booking admin CRUD removed
// Form Management // Form Management
router.get("/form", ensureAuthenticated, formController.index); router.get("/form", ensureAuthenticated, formController.index);
router.post( router.post("/form/update", ensureAuthenticated, formController.updateDefaultForm);
"/form/update",
ensureAuthenticated,
formController.updateDefaultForm,
);
// Upload routes // Upload routes
router.get("/upload", ensureAuthenticated, (req, res) => { router.get("/upload", ensureAuthenticated, (req, res) => {
@@ -99,80 +75,30 @@ router.get("/upload", ensureAuthenticated, (req, res) => {
user: req.session.user, user: req.session.user,
}); });
}); });
router.post( router.post("/upload/image", ensureAuthenticated, upload.single("image"), uploadController.uploadImage);
"/upload/image", router.post("/upload/video", ensureAuthenticated, uploadVideo.single("video"), uploadController.uploadVideo);
ensureAuthenticated, router.post("/upload/update-path", ensureAuthenticated, uploadController.updateImagePath);
upload.single("image"), router.post("/upload/delete", ensureAuthenticated, uploadController.deleteImage);
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.get("/header", ensureAuthenticated, headerController.index);
router.post("/header/update", ensureAuthenticated, headerController.update); router.post("/header/update", ensureAuthenticated, headerController.update);
router.get("/header/data", ensureAuthenticated, headerController.api); // Normalized from getHeaderData router.get("/header/data", ensureAuthenticated, headerController.api); // Normalized from getHeaderData
router.patch( router.patch("/header/:id/status", ensureAuthenticated, headerController.updateStatus);
"/header/:id/status",
ensureAuthenticated,
headerController.updateStatus,
);
router.delete("/header/:id", ensureAuthenticated, headerController.destroy); router.delete("/header/:id", ensureAuthenticated, headerController.destroy);
// Header Menu INTEGRATED routes // Header Menu INTEGRATED routes
router.post( router.post("/header/menu/create", ensureAuthenticated, headerMenuController.store);
"/header/menu/create", router.post("/header/menu/update/:id", ensureAuthenticated, headerMenuController.update);
ensureAuthenticated, router.post("/header/menu/delete", ensureAuthenticated, headerMenuController.destroy);
headerMenuController.store, router.post("/header/menu/reorder", ensureAuthenticated, headerMenuController.reorder);
);
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 // Social Links routes
router.get("/social-links", ensureAuthenticated, socialLinkController.index); router.get("/social-links", ensureAuthenticated, socialLinkController.index);
router.post("/social-links", ensureAuthenticated, socialLinkController.store); router.post("/social-links", ensureAuthenticated, socialLinkController.store);
router.put( router.put("/social-links/:platform", ensureAuthenticated, socialLinkController.update);
"/social-links/:platform", router.delete("/social-links/:platform", ensureAuthenticated, socialLinkController.destroy);
ensureAuthenticated, router.post("/social-links/reorder", ensureAuthenticated, socialLinkController.reorder);
socialLinkController.update,
);
router.delete(
"/social-links/:platform",
ensureAuthenticated,
socialLinkController.destroy,
);
router.post(
"/social-links/reorder",
ensureAuthenticated,
socialLinkController.reorder,
);
// Footer routes // Footer routes
router.get("/footer", ensureAuthenticated, footerController.index); router.get("/footer", ensureAuthenticated, footerController.index);
@@ -182,160 +108,60 @@ router.get("/footer/data", ensureAuthenticated, footerController.getFooterData);
// Contact routes // Contact routes
router.get("/contact", ensureAuthenticated, contactController.index); router.get("/contact", ensureAuthenticated, contactController.index);
router.post("/contact/update", ensureAuthenticated, contactController.update); router.post("/contact/update", ensureAuthenticated, contactController.update);
router.get( router.get("/contact/data", ensureAuthenticated, contactController.getContactData);
"/contact/data",
ensureAuthenticated,
contactController.getContactData,
);
// Contact submissions management // Contact submissions management
router.get( router.get("/contact/submissions", ensureAuthenticated, contactController.getSubmissions);
"/contact/submissions", router.put("/contact/submissions/:id", ensureAuthenticated, contactController.updateSubmissionStatus);
ensureAuthenticated,
contactController.getSubmissions,
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus,
);
// Appointment management // Appointment management
const appointmentController = require("../controllers/appointmentController"); const appointmentController = require("../controllers/appointmentController");
router.get( router.get("/appointments", ensureAuthenticated, appointmentController.getAppointments);
"/appointments", router.get("/appointments/:id", ensureAuthenticated, appointmentController.getAppointmentById);
ensureAuthenticated, router.put("/appointments/:id", ensureAuthenticated, appointmentController.updateAppointmentStatus);
appointmentController.getAppointments, router.delete("/appointments/:id", ensureAuthenticated, appointmentController.deleteAppointment);
);
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 // Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index); router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post( router.post("/appointment/update", ensureAuthenticated, appointmentController.update);
"/appointment/update", router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData);
ensureAuthenticated,
appointmentController.update,
);
router.get(
"/appointment/data",
ensureAuthenticated,
appointmentController.getAppointmentData,
);
// Pricing CMS page management // Pricing CMS page management
const pricingController = require("../controllers/pricingController"); const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index); router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update); router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get( router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData);
"/pricing/data",
ensureAuthenticated,
pricingController.getPricingData,
);
// Activity CRUD routes // Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index); router.get("/activity", ensureAuthenticated, activityController.index);
router.get( router.get("/activity/create", ensureAuthenticated, activityController.createForm);
"/activity/create",
ensureAuthenticated,
activityController.createForm,
);
router.post("/activity/create", ensureAuthenticated, activityController.create); router.post("/activity/create", ensureAuthenticated, activityController.create);
// Update filters (place before any parameterized /activity/:id routes to avoid route collision) // Update filters (place before any parameterized /activity/:id routes to avoid route collision)
router.post( router.post("/activity/filters/update", ensureAuthenticated, activityController.updateFilters);
"/activity/filters/update",
ensureAuthenticated,
activityController.updateFilters,
);
// Update hero (global hero section for activities) // Update hero (global hero section for activities)
router.post( router.post("/activity/hero/update", ensureAuthenticated, activityController.updateHero);
"/activity/hero/update", router.get("/activity/:id/edit", ensureAuthenticated, activityController.editForm);
ensureAuthenticated, router.post("/activity/:id/update", ensureAuthenticated, activityController.update);
activityController.updateHero, router.post("/activity/:id/delete", ensureAuthenticated, activityController.delete);
); router.post("/activity/:id/toggle-status", ensureAuthenticated, activityController.toggleStatus);
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 // Update display order
router.post( router.post("/activity/update-order", ensureAuthenticated, activityController.updateOrder);
"/activity/update-order",
ensureAuthenticated,
activityController.updateOrder,
);
// Booking submissions routes // Booking submissions routes
router.get( router.get("/activity/:id/bookings/count", ensureAuthenticated, activityController.getBookingCount);
"/activity/:id/bookings/count", router.get("/activity/:id/bookings", ensureAuthenticated, activityController.getBookingSubmissions);
ensureAuthenticated, router.get("/activity/:id/bookings/export", ensureAuthenticated, activityController.exportBookingData);
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) // Export all bookings (across all activities)
router.get( router.get("/bookings/export-all", ensureAuthenticated, activityController.exportAllBookingsData);
"/bookings/export-all",
ensureAuthenticated,
activityController.exportAllBookingsData,
);
// Update booking submission // Update booking submission
router.put( router.put("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.updateBookingSubmission);
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.updateBookingSubmission,
);
// Delete booking submission // Delete booking submission
router.delete( router.delete("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.deleteBookingSubmission);
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.deleteBookingSubmission,
);
// Update filters // Update filters
// Preview activity // Preview activity
router.get( router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview);
"/activity/:id/preview",
ensureAuthenticated,
activityController.preview,
);
// FAQ routes // FAQ routes
router.get("/home/faq", ensureAuthenticated, faqController.index); router.get("/home/faq", ensureAuthenticated, faqController.index);
@@ -345,6 +171,10 @@ router.get("/home/faq/api", faqController.api);
// Deprecated FAQ API routes removed // Deprecated FAQ API routes removed
// 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.get("/terms-conditions", ensureAuthenticated, termsController.index); router.get("/terms-conditions", ensureAuthenticated, termsController.index);
router.post("/terms/update", ensureAuthenticated, termsController.update); router.post("/terms/update", ensureAuthenticated, termsController.update);
router.get("/terms/data", ensureAuthenticated, termsController.getTermsData); router.get("/terms/data", ensureAuthenticated, termsController.getTermsData);
@@ -361,6 +191,14 @@ router.get("/travel/seed", ensureAuthenticated, travelController.seed);
// Deprecated FAQ API routes removed // Deprecated FAQ API routes removed
// 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);
// API routes cho sidebar navigation (AJAX calls)
router.put("/faq/api/update-sidebar", ensureAuthenticated, faqController.updateSidebarNav);
// Safety routes // Safety routes
router.get("/safety", ensureAuthenticated, safetyController.index); router.get("/safety", ensureAuthenticated, safetyController.index);
@@ -368,36 +206,16 @@ router.post("/safety/update", ensureAuthenticated, safetyController.update);
//Insurance routes //Insurance routes
router.get("/insurance", ensureAuthenticated, insuranceController.index); router.get("/insurance", ensureAuthenticated, insuranceController.index);
router.post( router.post("/insurance/update", ensureAuthenticated, insuranceController.update);
"/insurance/update",
ensureAuthenticated,
insuranceController.update,
);
// Service routes // Service routes
router.get("/service", ensureAuthenticated, serviceController.index); router.get("/service", ensureAuthenticated, serviceController.index);
router.post("/service/update", ensureAuthenticated, serviceController.update); router.post("/service/update", ensureAuthenticated, serviceController.update);
router.post( router.post("/service/generate-slug", ensureAuthenticated, serviceController.generateSlug);
"/service/generate-slug",
ensureAuthenticated,
serviceController.generateSlug,
);
router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit); router.get("/service/:slug/edit", ensureAuthenticated, serviceController.edit);
router.post( router.post("/service/:slug/edit", ensureAuthenticated, serviceController.updateService);
"/service/:slug/edit", router.get("/service/:slug/details", ensureAuthenticated, serviceController.details);
ensureAuthenticated, router.post("/service/:slug/details/update", ensureAuthenticated, serviceController.updateDetails);
serviceController.updateService,
);
router.get(
"/service/:slug/details",
ensureAuthenticated,
serviceController.details,
);
router.post(
"/service/:slug/details/update",
ensureAuthenticated,
serviceController.updateDetails,
);
// Test Image Paths route // Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => { router.get("/test-images", ensureAuthenticated, (req, res) => {
@@ -430,9 +248,7 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
type: "Location", type: "Location",
name: location.title, name: location.title,
path: location.imageSrc, path: location.imageSrc,
exists: fs.existsSync( exists: fs.existsSync(path.join(__dirname, "../public", location.imageSrc)),
path.join(__dirname, "../public", location.imageSrc),
),
}); });
} }
@@ -444,9 +260,7 @@ router.get("/test-images", ensureAuthenticated, (req, res) => {
type: "Program", type: "Program",
name: program.title, name: program.title,
path: program.imageSrc, path: program.imageSrc,
exists: fs.existsSync( exists: fs.existsSync(path.join(__dirname, "../public", program.imageSrc)),
path.join(__dirname, "../public", program.imageSrc),
),
}); });
} }
}); });
@@ -475,18 +289,10 @@ router.post("/visa/update", ensureAuthenticated, visaController.updateCountry);
router.post("/visa/add", ensureAuthenticated, visaController.addCountry); router.post("/visa/add", ensureAuthenticated, visaController.addCountry);
// Update single country // Update single country
router.put( router.put("/visa/update/:id", ensureAuthenticated, visaController.updateCountry);
"/visa/update/:id",
ensureAuthenticated,
visaController.updateCountry,
);
// Delete country // Delete country
router.delete( router.delete("/visa/delete/:id", ensureAuthenticated, visaController.deleteCountry);
"/visa/delete/:id",
ensureAuthenticated,
visaController.deleteCountry,
);
// Blog routes // Blog routes
// Blog Management Routes // Blog Management Routes
router.get("/blog", ensureAuthenticated, blogController.index); router.get("/blog", ensureAuthenticated, blogController.index);
@@ -497,79 +303,27 @@ router.post("/blog/:id/edit", ensureAuthenticated, blogController.update);
router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy); router.post("/blog/:id/delete", ensureAuthenticated, blogController.destroy);
// Comment management routes // Comment management routes
router.post( router.post("/blog/:blogId/comments/:commentId/approve", ensureAuthenticated, blogController.approveComment);
"/blog/:blogId/comments/:commentId/approve", router.post("/blog/:blogId/comments/:commentId/reject", ensureAuthenticated, blogController.rejectComment);
ensureAuthenticated, router.post("/blog/:blogId/comments/:commentId/delete", ensureAuthenticated, blogController.deleteComment);
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 // Blog Categories Management
router.get( router.get("/blog/categories", ensureAuthenticated, blogCategoryController.index);
"/blog/categories", router.get("/blog/categories/create", ensureAuthenticated, blogCategoryController.create);
ensureAuthenticated, router.post("/blog/categories/create", ensureAuthenticated, blogCategoryController.store);
blogCategoryController.index, router.get("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.edit);
); router.post("/blog/categories/:id/edit", ensureAuthenticated, blogCategoryController.update);
router.get( router.post("/blog/categories/:id/delete", ensureAuthenticated, blogCategoryController.destroy);
"/blog/categories/create", router.post("/blog/categories/quick-create", ensureAuthenticated, blogCategoryController.quickCreate);
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 // Blog Tags Management
router.get("/blog/tags", ensureAuthenticated, blogTagController.index); router.get("/blog/tags", ensureAuthenticated, blogTagController.index);
router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create); router.get("/blog/tags/create", ensureAuthenticated, blogTagController.create);
router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store); router.post("/blog/tags/create", ensureAuthenticated, blogTagController.store);
router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit); router.get("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.edit);
router.post( router.post("/blog/tags/:id/edit", ensureAuthenticated, blogTagController.update);
"/blog/tags/:id/edit", router.post("/blog/tags/:id/delete", ensureAuthenticated, blogTagController.destroy);
ensureAuthenticated, router.post("/blog/tags/quick-create", ensureAuthenticated, blogTagController.quickCreate);
blogTagController.update,
);
router.post(
"/blog/tags/:id/delete",
ensureAuthenticated,
blogTagController.destroy,
);
router.post(
"/blog/tags/quick-create",
ensureAuthenticated,
blogTagController.quickCreate,
);
// Testimonials management // Testimonials management
router.get("/home/testimonials", ensureAuthenticated, testimonialController.index); router.get("/home/testimonials", ensureAuthenticated, testimonialController.index);

View File

@@ -58,8 +58,9 @@ router.get("/api/header-menu", headerMenuController.api);
router.get("/api/social-links", socialLinkController.index); router.get("/api/social-links", socialLinkController.index);
router.get("/api/social-links/:platform", socialLinkController.show); router.get("/api/social-links/:platform", socialLinkController.show);
// Footer API route // Footer API routes
router.get("/api/footer", footerController.api); router.get("/api/footer", footerController.getFooter);
router.put("/api/admin/footer", footerController.updateFooter);
// Contact API route // Contact API route
router.get("/api/contact", contactController.api); router.get("/api/contact", contactController.api);
@@ -182,6 +183,13 @@ router.get("/api/testimonials", testimonialController.api);
// Video Gallery API // Video Gallery API
const videoGalleryController = require("../controllers/videoGalleryController"); const videoGalleryController = require("../controllers/videoGalleryController");
router.get("/api/video-gallery", videoGalleryController.api); router.get("/api/video-gallery", videoGalleryController.api);
// Test route for footer
router.get("/test-footer", (req, res) => {
res.render("test-footer", {
title: "Footer Test",
layout: "layouts/main",
});
});
module.exports = router; module.exports = router;

View 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);
});
}

View 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

View File

@@ -826,47 +826,18 @@
</main> </main>
<!-- Footer --> <!-- Footer -->
<footer class="mt-5"> <footer class="mt-5" id="dynamicFooter">
<div class="container py-4"> <div class="container py-4">
<div class="row"> <div class="row" id="footerContent">
<div class="col-md-4 mb-4 mb-md-0"> <!-- Footer content will be loaded from API -->
<h5 class="mb-3">CMS.HAILearning</h5> <div class="col-12 text-center">
<p>Simple and effective API management system.</p> <p>Loading footer...</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> </div>
</div> </div>
<hr class="my-4" /> <hr class="my-4" />
<div class="text-center"> <div class="text-center">
<p class="mb-0"> <p class="mb-0" id="footerCopyright">
&copy; <%= new Date().getFullYear() %> CMS.HAILearning. All rights &copy; <%= new Date().getFullYear() %> Loading...
reserved.
</p> </p>
</div> </div>
</div> </div>
@@ -1026,6 +997,198 @@
}); });
</script> </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 %> <%- script %>
</body> </body>

98
views/test-footer.ejs Normal file
View 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>