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,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;

View File

@@ -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"
}
}

View File

@@ -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);

View File

@@ -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 ====================

View File

@@ -44,8 +44,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
@@ -54,125 +54,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);
@@ -182,160 +108,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
router.get("/home/faq", ensureAuthenticated, faqController.index);
@@ -345,6 +171,10 @@ router.get("/home/faq/api", faqController.api);
// 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.post("/terms/update", ensureAuthenticated, termsController.update);
router.get("/terms/data", ensureAuthenticated, termsController.getTermsData);
@@ -361,6 +191,14 @@ router.get("/travel/seed", ensureAuthenticated, travelController.seed);
// 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
router.get("/safety", ensureAuthenticated, safetyController.index);
@@ -368,98 +206,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
@@ -475,18 +289,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);
@@ -497,79 +303,27 @@ 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);
// Testimonials management
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/: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);
@@ -182,6 +183,13 @@ router.get("/api/testimonials", testimonialController.api);
// Video Gallery API
const videoGalleryController = require("../controllers/videoGalleryController");
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;

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>
<!-- 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">
&copy; <%= new Date().getFullYear() %> CMS.HAILearning. All rights
reserved.
<p class="mb-0" id="footerCopyright">
&copy; <%= new Date().getFullYear() %> Loading...
</p>
</div>
</div>
@@ -1026,6 +997,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
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>