forked from UKSOURCE/cms.hailearning.edu.vn
Add CMS support for floating contact widget with Facebook/Zalo quick actions. Includes mongoose schema, admin UI tab, image upload with sharp resize presets, deferred form submission with draft persistence, and upload middleware error handling.
433 lines
12 KiB
JavaScript
433 lines
12 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 getHomeData = async () => (await getHomeDoc())?.toObject() || {};
|
|
const getOrCreateHomeDoc = async () => {
|
|
let doc = await getHomeDoc();
|
|
|
|
if (!doc) {
|
|
doc = new Home(getDefaultHomeData());
|
|
}
|
|
|
|
return doc;
|
|
};
|
|
|
|
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: "Anh chị cần tư vấn hay hỗ trợ gì thêm không ạ?",
|
|
brand: {
|
|
imageSrc: "/assets/img/logo/black-logo.svg",
|
|
imageAlt: "HAI Learning",
|
|
},
|
|
trigger: {
|
|
imageSrc: "",
|
|
icon: "fa-comments",
|
|
},
|
|
actions: [
|
|
{
|
|
id: "facebook",
|
|
platform: "facebook",
|
|
enabled: true,
|
|
label: "Nhắn tin qua 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: "Nhắn tin qua 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 = `${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" });
|
|
}
|
|
};
|