forked from UKSOURCE/cms.hailearning.edu.vn
506 lines
15 KiB
JavaScript
506 lines
15 KiB
JavaScript
const {
|
|
addBaseUrlToImages,
|
|
getFullImageUrl,
|
|
} = require("../utils/imageHelper");
|
|
const Home = require("../models/home");
|
|
const Blog = require("../models/blog");
|
|
const writeAuditLog = require("../audit/writeAuditLog");
|
|
const diffObject = require("../audit/diffObject");
|
|
const AUDIT_ACTIONS = require("../constants/auditAction");
|
|
|
|
// Các hàm hỗ trợ
|
|
const getHomeDoc = async () => Home.findOne().sort({ updatedAt: -1 });
|
|
const getAllHomeDocs = async () => Home.find().sort({ updatedAt: -1 });
|
|
const getHomeData = async () => (await getHomeDoc())?.toObject() || {};
|
|
const getOrCreateHomeDoc = async () => {
|
|
let doc = await getHomeDoc();
|
|
|
|
if (!doc) {
|
|
doc = new Home(getDefaultHomeData());
|
|
}
|
|
|
|
return doc;
|
|
};
|
|
|
|
const hasMeaningfulValue = (value) =>
|
|
typeof value === "string" && value.trim().length > 0;
|
|
|
|
const getMeaningfulHeroSlides = (hero = {}) => {
|
|
if (!Array.isArray(hero.slides)) return [];
|
|
|
|
return hero.slides.filter((slide = {}) => {
|
|
return (
|
|
hasMeaningfulValue(slide.title) ||
|
|
hasMeaningfulValue(slide.subtitle) ||
|
|
hasMeaningfulValue(slide.description) ||
|
|
hasMeaningfulValue(slide.heroImage) ||
|
|
hasMeaningfulValue(slide.videoUrl) ||
|
|
hasMeaningfulValue(slide.primaryButton?.label) ||
|
|
hasMeaningfulValue(slide.primaryButton?.href) ||
|
|
hasMeaningfulValue(slide.secondaryButton?.label) ||
|
|
hasMeaningfulValue(slide.secondaryButton?.href)
|
|
);
|
|
});
|
|
};
|
|
|
|
const scoreHeroSlides = (slides = []) =>
|
|
slides.reduce((score, slide = {}) => {
|
|
return (
|
|
score +
|
|
(slide.title || "").trim().length * 3 +
|
|
(slide.subtitle || "").trim().length +
|
|
(slide.description || "").trim().length * 2 +
|
|
(slide.primaryButton?.label || "").trim().length +
|
|
(slide.primaryButton?.href || "").trim().length +
|
|
(slide.secondaryButton?.label || "").trim().length +
|
|
(slide.secondaryButton?.href || "").trim().length +
|
|
(hasMeaningfulValue(slide.heroImage) ? 20 : 0) +
|
|
(hasMeaningfulValue(slide.videoUrl) ? 12 : 0)
|
|
);
|
|
}, 0);
|
|
|
|
const scoreHeroData = (hero = {}) => {
|
|
const slides = getMeaningfulHeroSlides(hero);
|
|
|
|
if (slides.length > 0) {
|
|
return scoreHeroSlides(slides) + slides.length * 30;
|
|
}
|
|
|
|
return (
|
|
(hero.title || "").trim().length * 3 +
|
|
(hero.subtitle || "").trim().length +
|
|
(hero.description || "").trim().length * 2 +
|
|
(hero.primaryButton?.label || "").trim().length +
|
|
(hero.primaryButton?.href || "").trim().length +
|
|
(hero.secondaryButton?.label || "").trim().length +
|
|
(hero.secondaryButton?.href || "").trim().length +
|
|
(hasMeaningfulValue(hero.heroImage) ? 20 : 0) +
|
|
(hasMeaningfulValue(hero.videoUrl) ? 12 : 0)
|
|
);
|
|
};
|
|
|
|
const getPreferredHeroData = (docs = []) => {
|
|
const heroes = docs
|
|
.map((doc) => doc?.hero)
|
|
.filter(Boolean);
|
|
|
|
if (!heroes.length) return {};
|
|
|
|
return heroes.reduce((bestHero, currentHero) => {
|
|
return scoreHeroData(currentHero) > scoreHeroData(bestHero)
|
|
? currentHero
|
|
: bestHero;
|
|
}, heroes[0]);
|
|
};
|
|
|
|
const normalizeStoredImagePath = (imagePath) => {
|
|
if (!imagePath || typeof imagePath !== "string") return "";
|
|
|
|
const raw = imagePath.trim();
|
|
if (!raw) return "";
|
|
|
|
const knownPrefixes = ["/uploads/", "/assets/", "/img/"];
|
|
|
|
for (const prefix of knownPrefixes) {
|
|
const index = raw.indexOf(prefix);
|
|
if (index >= 0) {
|
|
return raw.slice(index);
|
|
}
|
|
}
|
|
|
|
if (raw.startsWith("/")) {
|
|
return raw;
|
|
}
|
|
|
|
return `/${raw}`;
|
|
};
|
|
|
|
const getDefaultFloatingContactData = () => ({
|
|
enabled: true,
|
|
position: "bottom-right",
|
|
panelTitle: "Do you need any additional advice or support?",
|
|
brand: {
|
|
imageSrc: "/assets/img/logo/black-logo.svg",
|
|
imageAlt: "HAI Learning",
|
|
},
|
|
trigger: {
|
|
imageSrc: "",
|
|
icon: "fa-comments",
|
|
},
|
|
actions: [
|
|
{
|
|
id: "facebook",
|
|
platform: "facebook",
|
|
enabled: true,
|
|
label: "Message via Facebook",
|
|
subtitle: "facebook.com/hailearning.edu.vn",
|
|
href: "https://www.facebook.com/hailearning.edu.vn/",
|
|
iconImage: "/uploads/home/floating-contact/Facebook_Logo_Primary.webp",
|
|
iconType: "iconClass",
|
|
iconClass: "fa-brands fa-facebook-messenger",
|
|
iconText: "",
|
|
order: 1,
|
|
},
|
|
{
|
|
id: "zalo",
|
|
platform: "zalo",
|
|
enabled: true,
|
|
label: "Message via Zalo",
|
|
subtitle: "zalo.me/84961834040",
|
|
href: "https://zalo.me/84961834040",
|
|
iconImage: "/uploads/home/floating-contact/Icon_of_Zalo.svg.webp",
|
|
iconType: "iconText",
|
|
iconClass: "",
|
|
iconText: "Zalo",
|
|
order: 2,
|
|
},
|
|
],
|
|
});
|
|
|
|
const normalizeFloatingContactData = (payload = {}) => {
|
|
const defaults = getDefaultFloatingContactData();
|
|
const brand = payload.brand || {};
|
|
const trigger = payload.trigger || {};
|
|
const hasProvidedActions = Array.isArray(payload.actions);
|
|
const rawActions = hasProvidedActions ? payload.actions : [];
|
|
|
|
const actions = rawActions
|
|
.map((action, index) => ({
|
|
id: String(action.id || `${action.platform || "action"}-${index + 1}`),
|
|
platform: String(action.platform || "").trim(),
|
|
enabled: action.enabled !== false,
|
|
label: String(action.label || "").trim(),
|
|
subtitle: String(action.subtitle || "").trim(),
|
|
href: String(action.href || "").trim(),
|
|
iconImage: normalizeStoredImagePath(String(action.iconImage || "").trim()),
|
|
iconType: action.iconType === "iconText" ? "iconText" : "iconClass",
|
|
iconClass: String(action.iconClass || "").trim(),
|
|
iconText: String(action.iconText || "").trim(),
|
|
order: Number.isFinite(Number(action.order)) ? Number(action.order) : index + 1,
|
|
}))
|
|
.filter((action) => {
|
|
return (
|
|
action.platform ||
|
|
action.label ||
|
|
action.subtitle ||
|
|
action.href ||
|
|
action.iconImage ||
|
|
action.iconClass ||
|
|
action.iconText
|
|
);
|
|
})
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((action, index) => ({
|
|
...action,
|
|
order: index + 1,
|
|
}));
|
|
|
|
return {
|
|
enabled: payload.enabled !== false,
|
|
position: payload.position || defaults.position,
|
|
panelTitle: String(payload.panelTitle || defaults.panelTitle).trim(),
|
|
brand: {
|
|
imageSrc: normalizeStoredImagePath(
|
|
String(brand.imageSrc || defaults.brand.imageSrc).trim(),
|
|
),
|
|
imageAlt: String(brand.imageAlt || defaults.brand.imageAlt).trim(),
|
|
},
|
|
trigger: {
|
|
imageSrc: normalizeStoredImagePath(
|
|
String(trigger.imageSrc || "").trim(),
|
|
),
|
|
icon: String(trigger.icon || defaults.trigger.icon).trim() || defaults.trigger.icon,
|
|
},
|
|
actions: hasProvidedActions ? actions : defaults.actions,
|
|
};
|
|
};
|
|
|
|
const getDefaultHomeData = () => ({
|
|
hero: {
|
|
backgroundImage: "",
|
|
slides: [],
|
|
title: "",
|
|
subtitle: "",
|
|
description: "",
|
|
heroImage: "",
|
|
videoUrl: "",
|
|
primaryButton: {},
|
|
secondaryButton: {},
|
|
},
|
|
whyChooseUs: {
|
|
heading: "",
|
|
subheading: "",
|
|
description: "",
|
|
highlightWord: "",
|
|
mainImage: "",
|
|
secondaryImage: "",
|
|
items: [],
|
|
features: [],
|
|
ctaButton: {},
|
|
},
|
|
visaSolutions: { heading: "", subheading: "", items: [] },
|
|
visaCountries: {
|
|
heading: "",
|
|
subheading: "",
|
|
description: "",
|
|
countries: [],
|
|
ctaButton: {},
|
|
},
|
|
testimonials: {
|
|
heading: "",
|
|
subheading: "",
|
|
videoUrl: "",
|
|
videoThumbnail: "",
|
|
items: [],
|
|
},
|
|
videoGallery: { heading: "", videoUrl: "", thumbnail: "" },
|
|
faq: {
|
|
heading: "",
|
|
subheading: "",
|
|
description: "",
|
|
ctaButton: {},
|
|
items: [],
|
|
},
|
|
achievements: { heading: "", subheading: "", items: [] },
|
|
partners: { visaConsultancy: { items: [] }, brands: { items: [] } },
|
|
blogPreview: {
|
|
heading: "Latest Insights & Updates",
|
|
subheading: "Visa Tips & Guides",
|
|
ctaButton: { label: "View All Articles", href: "/blog" },
|
|
items: [],
|
|
selectedBlogIds: [], // Array of manually selected blog IDs
|
|
},
|
|
floatingContact: getDefaultFloatingContactData(),
|
|
});
|
|
|
|
// Admin: Xem trang quản lý
|
|
exports.index = async (req, res) => {
|
|
try {
|
|
let data = await getHomeData();
|
|
const defaults = getDefaultHomeData();
|
|
|
|
// Merge dữ liệu mặc định cho tất cả các phần
|
|
const sections = Object.keys(defaults);
|
|
sections.forEach((s) => {
|
|
data[s] = data[s] || defaults[s];
|
|
});
|
|
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
|
|
|
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
|
const backendUrl = `${req.protocol}://${req.get("host")}`;
|
|
|
|
// Lấy tất cả blog để chọn trong CMS
|
|
const allBlogs = await Blog.find({ status: "published" })
|
|
.sort({ createdAt: -1 })
|
|
.lean();
|
|
|
|
return res.render("admin/home/index", {
|
|
layout: "layouts/main",
|
|
title: "Home Management",
|
|
data,
|
|
allBlogs,
|
|
frontendUrl,
|
|
backendUrl,
|
|
getFullImageUrl,
|
|
currentPath: req.path,
|
|
user: req.session.user,
|
|
});
|
|
} catch (err) {
|
|
console.error("Home index error:", err);
|
|
req.flash("error_msg", "Error loading home data");
|
|
return req.session.save(() => res.redirect("/admin/dashboard"));
|
|
}
|
|
};
|
|
|
|
// Admin: Cập nhật dữ liệu (tập trung vào achievements, partners; các phần khác giữ nguyên nếu không có dữ liệu mới)
|
|
exports.update = async (req, res) => {
|
|
try {
|
|
const sections = [
|
|
"hero",
|
|
"whyChooseUs",
|
|
"visaSolutions",
|
|
"visaCountries",
|
|
"testimonials",
|
|
"videoGallery",
|
|
"faq",
|
|
"achievements",
|
|
"partners",
|
|
"blogPreview",
|
|
"floatingContact",
|
|
];
|
|
|
|
let doc = await getHomeDoc();
|
|
const beforeData = doc ? JSON.parse(JSON.stringify(doc.toObject())) : {};
|
|
|
|
if (!doc) {
|
|
doc = new Home({});
|
|
}
|
|
|
|
let hasChanges = false;
|
|
const updatedSections = [];
|
|
|
|
for (const section of sections) {
|
|
if (req.body[section]) {
|
|
try {
|
|
const payload = JSON.parse(req.body[section]);
|
|
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema
|
|
doc[section] =
|
|
section === "floatingContact"
|
|
? normalizeFloatingContactData(payload)
|
|
: payload;
|
|
doc.markModified(section);
|
|
hasChanges = true;
|
|
updatedSections.push(section);
|
|
} catch (e) {
|
|
console.error(`Invalid JSON for ${section}:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasChanges) {
|
|
req.flash("info_msg", "No changes were made");
|
|
return req.session.save(() => res.redirect("/admin/home"));
|
|
}
|
|
|
|
await doc.save();
|
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
|
|
|
// ✅ AUDIT LOGGING - Home Update
|
|
const changes = diffObject(beforeData, afterData);
|
|
if (changes.length > 0) {
|
|
await writeAuditLog({
|
|
model: "Home",
|
|
documentId: doc._id,
|
|
action: AUDIT_ACTIONS.UPDATE_HOME,
|
|
before: beforeData,
|
|
after: afterData,
|
|
changes,
|
|
req,
|
|
});
|
|
}
|
|
|
|
req.flash("success_msg", "Home page configuration has been updated!");
|
|
return req.session.save(() => res.redirect("/admin/home"));
|
|
} catch (err) {
|
|
console.error("Home update error:", err);
|
|
req.flash("error_msg", `Update error: ${err.message}`);
|
|
return req.session.save(() => res.redirect("/admin/home"));
|
|
}
|
|
};
|
|
|
|
exports.updateFloatingContact = async (req, res) => {
|
|
try {
|
|
const payload =
|
|
typeof req.body?.floatingContact === "string"
|
|
? JSON.parse(req.body.floatingContact)
|
|
: req.body?.floatingContact || req.body;
|
|
|
|
const doc = await getOrCreateHomeDoc();
|
|
const beforeData = JSON.parse(JSON.stringify(doc.toObject()));
|
|
|
|
doc.floatingContact = normalizeFloatingContactData(payload);
|
|
doc.markModified("floatingContact");
|
|
await doc.save();
|
|
|
|
const afterData = JSON.parse(JSON.stringify(doc.toObject()));
|
|
const changes = diffObject(beforeData, afterData);
|
|
|
|
if (changes.length > 0) {
|
|
await writeAuditLog({
|
|
model: "Home",
|
|
documentId: doc._id,
|
|
action: AUDIT_ACTIONS.UPDATE_HOME,
|
|
before: beforeData,
|
|
after: afterData,
|
|
changes,
|
|
req,
|
|
});
|
|
}
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
message: "Floating contact updated successfully",
|
|
floatingContact: doc.floatingContact,
|
|
});
|
|
} catch (err) {
|
|
console.error("Floating contact update error:", err);
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: err.message || "Failed to update floating contact",
|
|
});
|
|
}
|
|
};
|
|
|
|
// Public API// API lấy danh sách blog cho CMS
|
|
exports.apiGetBlogs = async (req, res) => {
|
|
try {
|
|
const blogs = await Blog.find({ status: "published" })
|
|
.sort({ createdAt: -1 })
|
|
.select("title slug featuredImage author publishedAt")
|
|
.lean();
|
|
res.json(blogs);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
};
|
|
exports.api = async (req, res) => {
|
|
try {
|
|
let data = await getHomeData();
|
|
const baseUrl =
|
|
process.env.BACKEND_URL ?? `${req.protocol}://${req.get("host")}`;
|
|
|
|
// === Xử lý Blog Preview động ===
|
|
const blogPreview = data.blogPreview || {};
|
|
let blogs = [];
|
|
|
|
// Nếu có chọn blog cụ thể
|
|
if (blogPreview.selectedBlogIds && blogPreview.selectedBlogIds.length > 0) {
|
|
blogs = await Blog.find({
|
|
_id: { $in: blogPreview.selectedBlogIds },
|
|
status: "published",
|
|
}).lean();
|
|
|
|
// Sắp xếp theo thứ tự đã chọn trong selectedBlogIds
|
|
blogs.sort((a, b) => {
|
|
return (
|
|
blogPreview.selectedBlogIds.indexOf(a._id.toString()) -
|
|
blogPreview.selectedBlogIds.indexOf(b._id.toString())
|
|
);
|
|
});
|
|
}
|
|
|
|
// Nếu không chọn hoặc chọn nhưng không đủ, lấy thêm 3 bài mới nhất (hoặc bù vào)
|
|
if (blogs.length === 0) {
|
|
blogs = await Blog.find({ status: "published" })
|
|
.sort({ createdAt: -1 })
|
|
.limit(3)
|
|
.lean();
|
|
}
|
|
|
|
// Map dữ liệu blog sang format mà frontend mong đợi
|
|
blogPreview.items = blogs.map((blog) => ({
|
|
title: blog.title,
|
|
excerpt: blog.excerpt,
|
|
category: blog.category && blog.category[0] ? blog.category[0] : "Visa",
|
|
date: blog.publishedAt || blog.createdAt,
|
|
author: {
|
|
name: blog.author || "Admin",
|
|
avatar: "", // Frontend đang tự xử lý hoặc dùng logo hệ thống
|
|
},
|
|
comments: blog.commentsCount || 0,
|
|
link: `/blog/${blog.slug}`,
|
|
thumbnail: blog.featuredImage,
|
|
}));
|
|
|
|
data.blogPreview = blogPreview;
|
|
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
|
|
// ===============================
|
|
|
|
const processed = addBaseUrlToImages(data, baseUrl);
|
|
return res.json(processed);
|
|
} catch (err) {
|
|
console.error("Home API error:", err);
|
|
return res.status(500).json({ error: "Error loading home data" });
|
|
}
|
|
};
|