forked from UKSOURCE/cms.hailearning.edu.vn
feat(contact-button): add floating contact widget admin management
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.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
const { addBaseUrlToImages, getFullImageUrl } = require("../utils/imageHelper");
|
||||
const {
|
||||
addBaseUrlToImages,
|
||||
getFullImageUrl,
|
||||
} = require("../utils/imageHelper");
|
||||
const Home = require("../models/home");
|
||||
const Blog = require("../models/blog");
|
||||
const writeAuditLog = require("../audit/writeAuditLog");
|
||||
@@ -8,6 +11,137 @@ 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: {
|
||||
@@ -64,6 +198,7 @@ const getDefaultHomeData = () => ({
|
||||
items: [],
|
||||
selectedBlogIds: [], // Array of manually selected blog IDs
|
||||
},
|
||||
floatingContact: getDefaultFloatingContactData(),
|
||||
});
|
||||
|
||||
// Admin: Xem trang quản lý
|
||||
@@ -77,9 +212,10 @@ exports.index = async (req, res) => {
|
||||
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 = process.env.BACKEND_URL || "http://localhost:3001";
|
||||
const backendUrl = `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// Lấy tất cả blog để chọn trong CMS
|
||||
const allBlogs = await Blog.find({ status: "published" })
|
||||
@@ -118,6 +254,7 @@ exports.update = async (req, res) => {
|
||||
"achievements",
|
||||
"partners",
|
||||
"blogPreview",
|
||||
"floatingContact",
|
||||
];
|
||||
|
||||
let doc = await getHomeDoc();
|
||||
@@ -135,7 +272,10 @@ exports.update = async (req, res) => {
|
||||
try {
|
||||
const payload = JSON.parse(req.body[section]);
|
||||
// Gán trực tiếp vào doc, Mongoose sẽ tự check schema
|
||||
doc[section] = payload;
|
||||
doc[section] =
|
||||
section === "floatingContact"
|
||||
? normalizeFloatingContactData(payload)
|
||||
: payload;
|
||||
doc.markModified(section);
|
||||
hasChanges = true;
|
||||
updatedSections.push(section);
|
||||
@@ -176,6 +316,49 @@ exports.update = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -191,8 +374,7 @@ exports.apiGetBlogs = async (req, res) => {
|
||||
exports.api = async (req, res) => {
|
||||
try {
|
||||
let data = await getHomeData();
|
||||
const baseUrl =
|
||||
process.env.BACKEND_URL || `${req.protocol}://${req.get("host")}`;
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// === Xử lý Blog Preview động ===
|
||||
const blogPreview = data.blogPreview || {};
|
||||
@@ -238,6 +420,7 @@ exports.api = async (req, res) => {
|
||||
}));
|
||||
|
||||
data.blogPreview = blogPreview;
|
||||
data.floatingContact = normalizeFloatingContactData(data.floatingContact);
|
||||
// ===============================
|
||||
|
||||
const processed = addBaseUrlToImages(data, baseUrl);
|
||||
|
||||
Reference in New Issue
Block a user